From e4256a1684b23de0eaa9190e6841eab1e9690718 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 6 Aug 2026 21:34:38 +0800 Subject: [PATCH 01/81] feat(web): steer the whole queue with an empty-draft Cmd/Ctrl+Enter --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 6 + .../2026-08-06-web-queue-steer-all-gesture.md | 30 +++++ ...26-08-06-web-queue-steer-all-gesture.zh.md | 30 +++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/input/contract.ts | 6 + .../src/client/input/facade.ts | 15 +++ .../ui-conversation/src/client/input/hub.ts | 33 +++++- .../src/client/skeleton/InputBar.tsx | 13 ++- .../ui-conversation/tests/input-bar.spec.tsx | 104 ++++++++++++++++-- .../tests/service-orchestration.spec.ts | 78 ++++++++++++- 13 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml new file mode 100644 index 0000000000..5b51304fe6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc +2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66 diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md new file mode 100644 index 0000000000..e65089fca7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -0,0 +1,30 @@ +# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter + +Status: implemented + +English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md) + +## Problem + +While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything". + +## Decision + +Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary. + +The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. + +## Consequences + +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative. + +## Related + +The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision. + +## Alternatives considered + +- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op. +- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. +- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. +- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md new file mode 100644 index 0000000000..abccf60a9d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话 + +Status: implemented + +[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文 + +## Problem + +主会话运行时,用户用普通 Enter(或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮;而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝,Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。 + +## Decision + +空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()`。`SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`(pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`(flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。 + +该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 + +## Consequences + +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 + +## Related + +逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。 + +## Alternatives considered + +- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。 +- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 +- **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 +- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index be53206dde..73b3204d61 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: bbd115eac0eb914914dc11e504639633c801abdd -README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc +README.md: e7b57e25208a402a81284f930644bfc4208c9291 +README.zh.md: 1b8543fa24d8e8a62f6b8995619548928ab80ce1 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bbd115eac0..e7b57e2520 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 843b49e311..1b8543fa24 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..0cdb4b9939 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -131,7 +131,7 @@ export function apply(ctx: Context): void { // The per-session input machine registry (InputService face; published as // ctx.conversation.input by the service below sharing this one instance). - const inputHub = new InputHub(ctx) + const inputHub = new InputHub(ctx, t) // Decision 19/20: the input machine feeds every session-scope slot // component through the standard provide channel — the 'input' hook plus diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 6af78bdb72..bc4b365373 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -88,6 +88,12 @@ export interface ComposerKeyboard { setDraft(text: string, editRange?: EditRange): void /** Submit with an explicit delivery mode resolved by the keyboard policy. */ submit(mode: InputSubmitMode): void + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture; the queue dock's per-row steer + * button is the same operation applied to the whole queue). + */ + steerQueue(): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79516d04fa..fdceebd282 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -39,6 +39,11 @@ export interface SessionInputDeps { popup?: (() => PopupDismissFace | undefined) | undefined /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order (the empty-draft accelerated-Enter gesture); absent = unsupported. + */ + steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink(text: string, mode: InputSubmitMode): void } @@ -173,6 +178,16 @@ export class SessionInputShell implements SessionInput { return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass' } + /** + * Steer every still-pending queued message into the running turn (the + * empty-draft accelerated-Enter gesture). Execution belongs to the hub's + * queue choreography; absent dep = the gesture falls back to the machine's + * empty-draft no-op. + */ + steerQueue(): void { + this.deps.steerQueue?.() + } + /** * Space adjudication over the controller's hot state. * @returns true = a claim/insert was applied — the caller preventDefaults. diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 560999aa21..e9e58035d2 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -10,6 +10,7 @@ */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -25,8 +26,14 @@ interface CommandFace { export class InputHub implements InputService { private readonly shells = new Map() - /** @param ctx - client root context (services resolved lazily per call — boot order stays free). */ - constructor(private readonly rootCtx: ClientContext) {} + /** + * @param ctx - client root context (services resolved lazily per call — boot order stays free). + * @param t - conversation-namespace translate thunk (reads the active locale at call time). + */ + constructor( + private readonly rootCtx: ClientContext, + private readonly t: TranslateNS<'conversation'>, + ) {} /** * Resolve the facade for one session-scope ctx (InputService face). @@ -58,6 +65,7 @@ export class InputHub implements InputService { popup: () => this.popup(actx), queue: queueReadFaceOf(session), defaultSink: (text, mode) => { this.sink(session, text, mode) }, + steerQueue: () => { void this.steerQueue(session, shell) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -139,6 +147,27 @@ export class InputHub implements InputService { ) } + /** + * Steer every still-pending queued message into the running turn, in FIFO + * order — the same strict-steer operation as the queue dock's per-row + * button. A turn closing mid-way (`steer-unavailable`) or a row already + * claimed by the agent (`queue-item-not-found`) converges silently, while a + * genuine failure surfaces as one composer notice. + * @param session - the addressed host session. + * @param shell - the resident shell (notice outlet). + */ + private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise { + const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued') + if (queued.length === 0) return + for (const item of queued) { + const result = await session.updateQueue(item.id, { kind: 'steer' }) + if (result.ok) continue + if (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') return + shell.notify('error', this.t('queue.steerFailed')) + return + } + } + private controller(actx: ClientContext): SlashController | undefined { const slash = this.rootCtx.get('slash') return slash?.sessionOf(actx) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..7a85ef7e08 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -247,9 +247,20 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return + const accelerated = e.ctrlKey || e.metaKey + // Empty-draft accelerated Enter acts on the queue instead of the (empty) + // draft: the machine rejects empty drafts, so the gesture steers every + // still-pending queued message into the running turn (the dock's per-row + // steer button applied to the whole queue). Steering needs the same + // window as the per-row button: a running ordinary session. + if (accelerated && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued')) { + keyboard.steerQueue() + return + } keyboard.submit(resolveSubmitMode( running, - e.ctrlKey || e.metaKey ? 'accelerated' : 'enter', + accelerated ? 'accelerated' : 'enter', subagent === null, )) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..67a9b48e90 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,6 +56,10 @@ interface BenchOptions { subagent?: Exclude disabled?: boolean promptError?: ConversationSnapshot['promptError'] + /** Authoritative queue rows served to the machine overlay (empty = none). */ + queue?: ConversationSnapshot['queue'] + /** The hub's steer-all face (empty-draft accelerated Enter). */ + steerQueue?: () => void variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] @@ -69,14 +73,34 @@ interface BenchOptions { toggleCommandMenu?: (selection: { start: number; end: number }) => void } +/** One pending queue row (the runtime snapshot shape, as the dock tests build it). */ +function row(id: string): ConversationSnapshot['queue'][number] { + return { + id: id as never, messageId: `message-${id}` as never, placement: 'queued', + content: [{ type: 'text', text: id }], preview: id, text: id, + } +} + /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { const sink = vi.fn() const lex = over?.lexicon + const session = createSnapshotStore(snapshotOf({ + running: over?.running ?? false, + subagent: over?.subagent ?? null, + removed: over?.disabled ?? false, + promptError: over?.promptError ?? null, + queue: over?.queue ?? [], + })) type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, + queue: { + getSnapshot: () => session.getSnapshot().queue, + subscribe: fn => session.subscribe(fn), + }, + ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}), // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined @@ -88,12 +112,6 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - const session = createSnapshotStore(snapshotOf({ - running: over?.running ?? false, - subagent: over?.subagent ?? null, - removed: over?.disabled ?? false, - promptError: over?.promptError ?? null, - })) const stop = vi.fn() const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] @@ -147,7 +165,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, steerQueue: over?.steerQueue } } describe('Enter semantics', () => { @@ -190,6 +208,78 @@ describe('Enter semantics', () => { expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) + it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { + const steerQueue = vi.fn() + const queue = [row('q-1'), row('q-2')] + const meta = bench({ running: true, queue, steerQueue }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.steerQueue).toHaveBeenCalledTimes(1) + expect(meta.sink).not.toHaveBeenCalled() + + const ctrl = bench({ running: true, queue, steerQueue: vi.fn() }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.steerQueue).toHaveBeenCalledTimes(1) + expect(ctrl.sink).not.toHaveBeenCalled() + }) + + it('queue steering stays gated: idle, subagent, plain Enter, empty queue, or steering-only rows', () => { + // Idle: the gesture falls through to the machine's empty-draft no-op. + const idle = bench({ queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.steerQueue).not.toHaveBeenCalled() + expect(idle.sink).not.toHaveBeenCalled() + + // Plain Enter never steers the queue, even under the busy Steer preference. + const plain = bench({ running: true, busyEnter: 'steer', queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(plain.textarea, { key: 'Enter' }) + expect(plain.steerQueue).not.toHaveBeenCalled() + expect(plain.sink).not.toHaveBeenCalled() + + // Subagent sessions keep the queue transport (no steering face). + const subagent = { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + } + const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: vi.fn() }) + fireEvent.keyDown(child.textarea, { key: 'Enter', metaKey: true }) + expect(child.steerQueue).not.toHaveBeenCalled() + expect(child.sink).not.toHaveBeenCalled() + + // No queued rows: the empty draft stays a no-op. + const none = bench({ running: true, steerQueue: vi.fn() }) + fireEvent.keyDown(none.textarea, { key: 'Enter', metaKey: true }) + expect(none.steerQueue).not.toHaveBeenCalled() + expect(none.sink).not.toHaveBeenCalled() + + // Pending steering rows are not the queue: nothing to flush. + const steering = bench({ + running: true, + queue: [{ ...row('s-1'), placement: 'steering' }], + steerQueue: vi.fn(), + }) + fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true }) + expect(steering.steerQueue).not.toHaveBeenCalled() + expect(steering.sink).not.toHaveBeenCalled() + }) + + it('draft content outranks the queue: accelerated Enter steers the draft only', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) + fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) + expect(sink).toHaveBeenCalledWith('插话', 'steer') + expect(steerQueue).not.toHaveBeenCalled() + }) + + it('empty-draft accelerated Enter without a steerQueue face stays a silent no-op', () => { + const { textarea, sink } = bench({ running: true, queue: [row('q-1')] }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(sink).not.toHaveBeenCalled() + }) + it('platform undo/redo chords route to the machine, never the browser stack', () => { const { textarea, shell } = bench({ draft: '' }) fireEvent.change(textarea, { target: { value: 'first' } }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index ebd51f8408..126cb65a47 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -6,8 +6,11 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client' import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' import { InputHub } from '../src/client/input/hub.ts' +import { zh } from '../src/client/locales.ts' async function bench() { const runtime = await SlotTestRuntime.create() @@ -21,13 +24,13 @@ async function bench() { }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. - const fiber = runtime.ctx.plugin(ConversationService, { - input: new InputHub(runtime.ctx), - }) + const hub = new InputHub(runtime.ctx, makeTranslate(zh, {})) + const fiber = runtime.ctx.plugin(ConversationService, { input: hub }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } + const shell = hub.shellFor(runtime.sessions.binding('s1')!) + return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { @@ -85,9 +88,74 @@ describe('ConversationService', () => { // No SessionsService at all: a bare context (the runtime always provides one). const bare = new Context() await bare.plugin(ConversationService, { - input: new InputHub(bare), + input: new InputHub(bare, makeTranslate(zh, {})), }).await() const orphan = bare.get('conversation') as ConversationService await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) }) }) + +describe('InputHub queue steering (empty-draft accelerated Enter)', () => { + const row = (id: string): QueuedMessage => ({ + id: id as never, + messageId: `message-${id}` as never, + placement: 'queued', + content: [{ type: 'text', text: id }], + preview: id, + text: id, + }) + + it('steers every queued row in FIFO order and leaves steering rows alone', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')] + }) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.updateQueue).toHaveBeenCalledTimes(2) + }) + expect(b.updateQueue).toHaveBeenNthCalledWith(1, 'q-1', { kind: 'steer' }) + expect(b.updateQueue).toHaveBeenNthCalledWith(2, 'q-3', { kind: 'steer' }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('converges silently when the turn closes or a row is claimed mid-steer', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) + expect(b.shell.notices.getSnapshot()).toBeNull() + await b.runtime.dispose() + }) + + it('surfaces one notice on a genuine steer failure and stops', async () => { + const b = await bench() + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-1'), row('q-2')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'broken', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { + expect(b.shell.notices.getSnapshot()).toEqual( + expect.objectContaining({ level: 'error', text: '插话发送失败,请重试。' }), + ) + }) + expect(b.updateQueue).toHaveBeenCalledTimes(1) + await b.runtime.dispose() + }) + + it('no-ops without queued rows', async () => { + const b = await bench() + b.shell.steerQueue() + expect(b.updateQueue).not.toHaveBeenCalled() + await b.runtime.dispose() + }) +}) From 8ff75622c601ab156f59aac70fd6a86d31d7b4f6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 00:15:55 +0800 Subject: [PATCH 02/81] feat(bundle): default Windows hosts to the pwsh shell stack win32 hosts booting a shipped profile now get pwsh-local as the ctx.bash executor and tool-pwsh as the shell tool through the base bundle's new windows.cordis.patch.yml platform layer, injected by the launcher between the bundle layers and the user layers on win32. bash-sandbox, tool-bash, permission, and ui-permission are disabled there: the POSIX-only executor cannot run on Windows, and dsh-permission requires a confining executor. Overriding the default is a composition decision through the user's cordis.patch.yml; there is no environment override channel. apps/cli and dsh-base re-declare dsh-pwsh-local/dsh-tool-pwsh so the profile module fallback links them for cold starts (the profiles rework had dropped them from the CLI closure). Promotes the windows-pwsh-default Agent Note from proposed to implemented and documents the platform layer in the base bundle README. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 +- .../2026-08-01-pwsh-tool-and-executor.md | 2 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 ++ .../2026-08-01-windows-pwsh-default.md | 42 ++++++++++++ .../2026-08-01-windows-pwsh-default.zh.md | 42 ++++++++++++ .../2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 +- .../feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 6 -- .../2026-08-01-windows-pwsh-default.md | 39 ----------- .../2026-08-01-windows-pwsh-default.zh.md | 39 ----------- apps/cli/package.json | 2 + apps/cli/src/dump-config.ts | 7 ++ apps/cli/src/profile-boot.ts | 28 +++++--- apps/cli/src/windows-shell.ts | 55 ++++++++++++++++ apps/cli/tests/windows-shell.spec.ts | 64 +++++++++++++++++++ packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 5 +- packages/bundle/base/README.zh.md | 5 +- packages/bundle/base/package.json | 4 ++ packages/bundle/base/windows.cordis.patch.yml | 34 ++++++++++ pnpm-lock.yaml | 12 ++++ 23 files changed, 306 insertions(+), 104 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md create mode 100644 .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md delete mode 100644 .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md create mode 100644 apps/cli/src/windows-shell.ts create mode 100644 apps/cli/tests/windows-shell.spec.ts create mode 100644 packages/bundle/base/windows.cordis.patch.yml diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index d4ec255235..efd7d56dae 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6 -2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c +2026-08-01-pwsh-tool-and-executor.md: 77f0a13d0efa55c91b4e58ee2474ac6877947c80 +2026-08-01-pwsh-tool-and-executor.zh.md: 95c0d8f087f11d192350cf92ed866bb8a4ea33b4 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index 7206f8ffe6..77f0a13d0e 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -17,7 +17,7 @@ Two new packages under `packages/bash/`: Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. -The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md). +The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [the Windows pwsh default decision](2026-08-01-windows-pwsh-default.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index 5a48adb79f..95c0d8f087 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -17,7 +17,7 @@ harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 -本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。 +本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——已落地为 [Windows 默认 pwsh 决策](2026-08-01-windows-pwsh-default.md)。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml new file mode 100644 index 0000000000..e26d1ca354 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +2026-08-01-windows-pwsh-default.md: 1fa30ef757ccb81544479a08887355839fb86d46 +2026-08-01-windows-pwsh-default.zh.md: c6e1361dba29cff8340ef6e26bf071f81544642b diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md new file mode 100644 index 0000000000..1fa30ef757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -0,0 +1,42 @@ +# Agent Note: Windows defaults to pwsh + +Status: implemented + +English | [中文](2026-08-01-windows-pwsh-default.zh.md) + +## Problem + +The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior (hardcoded `bash -c` argv, process-group semantics); the model-facing bash tool teaches the bash dialect. The Windows-native foundation shipped in the [pwsh executor and tool decision](2026-08-01-pwsh-tool-and-executor.md) — a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but shipped compositions still mounted the bash stack on Windows, so a Windows host without a shim could not run the shipped shell. + +## Decision + +Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. + +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool), disables `permission`/`ui-permission` (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined pwsh executor cannot honor; see its constructor guard — and the client knob would advertise a shell it cannot enforce), and inserts `pwsh-local`/`tool-pwsh`. The fs tools keep the sandbox policy and approval service, so file confinement and escalation still apply on Windows. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. + +The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. + +## Alternatives considered + +**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. + +**Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. + +**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. File confinement keeps working through the fs stack. + +**Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. + +## Consequences + +- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. +- The permission switcher leaves the Windows roster; session permission facts pin the composition defaults through the approval service and sandbox policy. + +## Verification + +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected. +- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md new file mode 100644 index 0000000000..c6e1361dba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -0,0 +1,42 @@ +# Agent Note: Windows 默认改用 pwsh + +Status: implemented + +[English](2026-08-01-windows-pwsh-default.md) | 中文 + +## 问题 + +harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为(硬编码 `bash -c` argv、进程组语义);面向模型的 bash 工具教的是 bash 方言。Windows 原生基础已随 [pwsh 执行器与工具决策](2026-08-01-pwsh-tool-and-executor.md) 交付——`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但交付组合在 Windows 上仍然挂载 bash 栈,没有垫片的 Windows 主机跑不了交付的 shell。 + +## 决策 + +启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 + +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)、禁用 `permission`/`ui-permission`(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制 pwsh 执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个它无法强制执行的 shell),并插入 `pwsh-local`/`tool-pwsh`。fs 工具保留 sandbox 策略与批准服务,因此 Windows 上的文件限制与升级仍然生效。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 + +原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 + +## 备选方案 + +**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 + +**从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 + +**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。文件限制继续经由 fs 栈生效。 + +**交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 + +## 后果 + +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 +- 权限切换器离开 Windows 清单;会话权限事实通过批准服务与 sandbox 策略固定组合默认值。 + +## 验证 + +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入。 +- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..204560cfb5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 23693c833a779da7a0f52ab1c959bf7eb8649310 +2026-08-05-pwsh-ui-bash-parity.zh.md: ef44e9ff043c0a533d2cc34bb776c6eb3f0566f8 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..23693c833a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..ef44e9ff04 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml deleted file mode 100644 index fa7ea8e141..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 1c3ccef23bb5cd9bc37237bd69aac2e2c56649a8 -2026-08-01-windows-pwsh-default.zh.md: 3958d21eb8a9d306009b11d6e9806a1654a8958e diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md deleted file mode 100644 index 1c3ccef23b..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows defaults to pwsh (roadmap) - -Status: proposed - -English | [中文](2026-08-01-windows-pwsh-default.zh.md) - -## Problem - -The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them. - -## Proposal - -Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. - -1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. -2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed. - -The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior. - -## Alternatives considered - -**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config. - -**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible. - -**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior. - -## Acceptance criteria - -- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. -- POSIX hosts are byte-for-byte unaffected (same roster, same executor). -- The shipped-composition e2es assert the platform-gated roster on both families. -- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot). - -## Risks - -- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. -- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. -- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md deleted file mode 100644 index 3958d21eb8..0000000000 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Windows 默认改用 pwsh(路线图) - -Status: proposed - -[English](2026-08-01-windows-pwsh-default.md) | 中文 - -## 问题 - -harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片(WSL 或 Git-Bash),或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言,TUI/Web 表面以 bash 形状的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。 - -## 提案 - -两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 - -1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 -2. **pwsh GUI 渲染**——Web 表面以 bash 形状的终端呈现渲染 pwsh 调用(带退出状态 pill 的 terminal 卡),即 bash 终端卡片的对应物。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付;TUI 已移除,不再有终端孪生。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍无人认领。 - -各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。 - -## 备选方案 - -**在 `dsh-bash-local` 内部让 Windows 默认 pwsh(一个执行器,方言开关)。** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell,而按平台门控的组合是部署选择,不是执行器配置。 - -**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义),并且它属于带批准/PTY 表面可见的组合决策。 - -**用垫片在 Windows 上保留 bash,跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。 - -## 验收标准 - -- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 -- POSIX 主机逐字节不受影响(清单相同,执行器相同)。 -- 交付组合 e2e 在两个平台族上断言按平台门控的清单。 -- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地(TUI 的移除让终端表面无快照可做)。 - -## 风险 - -- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 -- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 -- **渲染约定**——bash 形状的终端孪生已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍是带快照表面的 UI 设计决策,随阶段 1 一起延期。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 87677ce1f9..11078458e6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -24,11 +24,13 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-web-app": "workspace:^", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index 9de7a55f60..29605878de 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -15,6 +15,7 @@ import { type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re label: layer.packageName, patches: layer.patches, })) + // The win32 shell platform layer rides between bundles and user layers, + // exactly where the boot applies it. + const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME) + if (windowsShellLayer !== undefined) { + layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches }) + } if (!defaultOnly) { if (existsSync(loaded.patchPath)) { layers.push({ label: loaded.patchPath, patches: loaded.patches }) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index b69a938213..7115f1208a 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -27,6 +27,7 @@ import { import { resolveDshHome } from '@deepseek-ai/dsh-paths' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' +import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -103,6 +104,8 @@ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ bundlePatches: PatchOptions[] + /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */ + windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ @@ -117,16 +120,23 @@ interface ComposedProfile { /** The full patch stack of one composed profile, in application order. */ function allPatches(composed: ComposedProfile): PatchOptions[] { - return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags] + return [ + ...composed.bundlePatches, + ...composed.windowsShellPatches, + ...composed.profile.patches, + ...composed.homePatches, + ...composed.overlayAndFlags, + ] } /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order, the profile's user layer, the home-level user layer - * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to - * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * `dsh.profile.bundles` order, the win32 shell platform layer (when the host + * is Windows), the + * profile's user layer, the home-level user layer (`$DSH_HOME/cordis.patch.yml` + * — machine-local preferences that apply to every profile, so it outranks the + * per-profile layer), `--patch` overlays, then flag patches derived from the + * composed rows, then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. @@ -141,14 +151,15 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) + const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] const rows = new Map() - for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { + for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, homePatches, overlayAndFlags, rows } + return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } } /** Options for {@link runProfile}. */ @@ -215,6 +226,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // removing the override could never revert the row to the bundle default. const composeLive = (): PatchOptions[] => structuredClone([ ...composed.bundlePatches, + ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlayAndFlags, diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts new file mode 100644 index 0000000000..425699ef4f --- /dev/null +++ b/apps/cli/src/windows-shell.ts @@ -0,0 +1,55 @@ +/** + * The Windows shell platform layer: on win32 hosts the shipped profile + * compositions swap the POSIX-only bash stack for the PowerShell stack + * (`@deepseek-ai/dsh-pwsh-local` + `@deepseek-ai/dsh-tool-pwsh`), matching + * the Windows-pwsh-default roadmap. The layer is the base bundle's + * `windows.cordis.patch.yml`, injected by the launcher between the bundle + * layers and the user layers so a user patch can still override it — the + * only override channel is composition config, like every other roster + * decision. POSIX hosts never receive the layer. + * @module @deepseek-ai/dsh/windows-shell + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' + +/** The base bundle whose package carries the Windows shell patch. */ +export const BASE_BUNDLE = '@deepseek-ai/dsh-base' + +/** The Windows shell patch filename inside the base bundle package. */ +export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml' + +/** One Windows shell platform layer: its patch file and parsed patches. */ +export interface WindowsShellLayer { + /** The patch file path, used as the config-dump provenance label. */ + label: string + /** The parsed patch entries, applied after the bundle layers. */ + patches: PatchOptions[] +} + +/** + * Resolve the Windows shell platform layer for a profile composition. + * @param platform - the host platform (`process.platform` at call sites). + * @param layers - the profile's bundle layers, in application order. + * @param binName - the diagnostic prefix on thrown errors (`dsh`). + * @returns the pwsh layer on win32, else `undefined`. A custom profile that + * mounts no base bundle is skipped (it owns its shell stack); a base + * bundle that ships no Windows shell patch fails loud — the shipped + * package always carries it, so a miss is a broken installation. + */ +export function resolveWindowsShellLayer( + platform: NodeJS.Platform, + layers: readonly ProfileLayer[], + binName: string, +): WindowsShellLayer | undefined { + if (platform !== 'win32') return undefined + const base = layers.find(layer => layer.packageName === BASE_BUNDLE) + if (base === undefined) return undefined + const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) + if (!existsSync(label)) { + throw new Error(`${binName}: ${BASE_BUNDLE} ships no ${WINDOWS_SHELL_PATCH_FILENAME}`) + } + return { label, patches: loadOverlayPatches(binName, label) } +} diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts new file mode 100644 index 0000000000..f0cfddbaae --- /dev/null +++ b/apps/cli/tests/windows-shell.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' +import { + BASE_BUNDLE, + resolveWindowsShellLayer, + WINDOWS_SHELL_PATCH_FILENAME, +} from '../src/windows-shell.ts' + +const WINDOWS_PATCH = `- id: bash-sandbox + disabled: true +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' +` + +/** One fake bundle layer rooted in a temp directory. */ +function fakeLayer(packageName: string, dir: string): ProfileLayer { + return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] } +} + +/** A base bundle layer whose package carries the Windows shell patch. */ +function baseLayerWithPatch(dir: string): ProfileLayer { + writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH) + return fakeLayer(BASE_BUNDLE, dir) +} + +describe('resolveWindowsShellLayer', () => { + let base: string + afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) }) + const tempBase = (): string => { + base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-')) + return base + } + + it('never applies on POSIX hosts', () => { + expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() + }) + + it('defaults Windows hosts to the pwsh platform layer', () => { + const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh') + expect(layer).toBeDefined() + expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) + expect(layer?.patches).toEqual([ + { id: 'bash-sandbox', disabled: true }, + { insert: [{ id: 'pwsh-local', name: '@deepseek-ai/dsh-pwsh-local' }] }, + ]) + }) + + it('skips custom profiles without a base bundle', () => { + const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase()) + expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined() + }) + + it('fails loud when the base bundle ships no Windows shell patch', () => { + const base = tempBase() + mkdirSync(base, { recursive: true }) + expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) + .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) + }) +}) diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 2ae7df0bdc..a47ac9cbc7 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 301d397d4c87687b382665cf63af47ab5e3f85be -README.zh.md: f007bc817b6cbad84725fe8abe72549cf67d8cd7 +README.md: a4f220c956f7f67d7810827d4488d54a071ad3d6 +README.zh.md: eaf96f8c0640481163e7a1c1533a07e35ecc294f diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 301d397d4c..a4f220c956 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. + +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash executor/tool and the permission stack (dsh-permission requires a confining executor), and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`). The launcher applies it between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -17,3 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Windows loses the permission switcher** — `dsh-permission` hard-requires a confining `ctx.bash` executor, so the Windows platform layer disables `permission`/`ui-permission` with the bash stack. The fs tools keep the sandbox policy and the approval service, so file confinement and escalation still apply on Windows. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index f007bc817b..eaf96f8c06 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 + +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 执行器/工具与权限栈(dsh-permission 要求有限权能力的执行器),并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -17,3 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Windows 上失去权限切换器**:`dsh-permission` 硬性要求有限权能力的 `ctx.bash` 执行器,因此 Windows 平台层随 bash 栈一起禁用 `permission`/`ui-permission`。fs 工具保留 sandbox 策略与批准服务,Windows 上的文件限制与升级仍然生效。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 95e169cabb..cb2b7178f7 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -16,6 +16,7 @@ "default": "./lib/invariant.js" }, "./cordis.patch.yml": "./cordis.patch.yml", + "./windows.cordis.patch.yml": "./windows.cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -23,6 +24,7 @@ "lib/index.js", "lib/invariant.js", "cordis.patch.yml", + "windows.cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -54,6 +56,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -82,6 +85,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml new file mode 100644 index 0000000000..59d5e81582 --- /dev/null +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -0,0 +1,34 @@ +# The dsh-base Windows platform layer: applied by the dsh launcher on win32 +# hosts, between the bundle layers and the user layers, replacing the +# POSIX-only bash stack with the PowerShell stack. The launcher reads THIS +# file from the base bundle package (never through dsh.bundle.patch — that +# field names the one universal layer). A Windows host that prefers bash +# overrides the rows here through its profile or home cordis.patch.yml. +# +# Windows hosts cannot run the shipped bash executor (POSIX-only: hardcoded +# `bash -c` argv and process-group semantics), so the shipped Windows +# experience is PowerShell-native: pwsh-local backs `ctx.bash` and tool-pwsh +# is the model-facing shell tool. dsh-permission requires a confining +# executor (its presets bundle a sandbox mode the unconfined pwsh executor +# cannot honor), so the permission service and its client knob leave the +# Windows roster with the bash stack; the fs tools keep the sandbox policy +# and the approval service, so file confinement and escalation still apply. + +- id: bash-sandbox + disabled: true + +- id: tool-bash + disabled: true + +- id: permission + disabled: true + +- id: ui-permission + disabled: true + +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b826046bea..453109bcae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@deepseek-ai/dsh-pty-local': specifier: workspace:^ version: link:../../packages/pty/pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-local '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference @@ -176,6 +179,9 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../packages/bash/tool-pwsh '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app @@ -922,6 +928,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard @@ -1006,6 +1015,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-pwsh': + specifier: workspace:^ + version: link:../../bash/tool-pwsh '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../workflow/tool-ralph From acac2149a5f8b3cbbbd26cd848ef73ac8792e7d2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 01:20:40 +0800 Subject: [PATCH 03/81] feat(bundle): degrade the Windows shell layer to danger-full-access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows platform layer previously kept fs path-rule confinement (sandbox-policy + fs-sandbox) next to the unconfined pwsh shell. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shell can bypass fs-only path rules with one command — the policy was theater. The layer now removes the whole sandbox stack (sandbox, sandbox-policy, fs-sandbox disabled), mounts the unconfined dsh-fs-local, and degrades to danger-full-access: permission/ui-permission leave the roster and the approval policy is 'never'. dsh-base declares dsh-fs-local so the profile module fallback links it for cold starts; base.spec.ts pins the shipped Windows roster (disables, inserts, approval policy); the Agent Note records the rejected fs-only confinement alternative. --- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 14 ++--- .../2026-08-01-windows-pwsh-default.zh.md | 14 ++--- packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 4 +- packages/bundle/base/README.zh.md | 4 +- packages/bundle/base/package.json | 1 + packages/bundle/base/tests/base.spec.ts | 53 +++++++++++++++++-- packages/bundle/base/windows.cordis.patch.yml | 42 ++++++++++----- 9 files changed, 103 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index e26d1ca354..711699aa0b 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 1fa30ef757ccb81544479a08887355839fb86d46 -2026-08-01-windows-pwsh-default.zh.md: c6e1361dba29cff8340ef6e26bf071f81544642b +2026-08-01-windows-pwsh-default.md: ceffd825c2316841dfbe01f4510243ba8748e500 +2026-08-01-windows-pwsh-default.zh.md: ea8058878fe268bdf1317620c853b8279c543532 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 1fa30ef757..ceffd825c2 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,9 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool), disables `permission`/`ui-permission` (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined pwsh executor cannot honor; see its constructor guard — and the client knob would advertise a shell it cannot enforce), and inserts `pwsh-local`/`tool-pwsh`. The fs tools keep the sandbox policy and approval service, so file confinement and escalation still apply on Windows. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` policy is `never`. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -24,19 +24,21 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w **Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. -**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. File confinement keeps working through the fs stack. +**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. + +**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access. **Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. ## Consequences - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval policy is `never`, and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. - Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. -- The permission switcher leaves the Windows roster; session permission facts pin the composition defaults through the approval service and sandbox policy. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected. +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the `never` approval policy). - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index c6e1361dba..ea8058878f 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,9 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)、禁用 `permission`/`ui-permission`(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制 pwsh 执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个它无法强制执行的 shell),并插入 `pwsh-local`/`tool-pwsh`。fs 工具保留 sandbox 策略与批准服务,因此 Windows 上的文件限制与升级仍然生效。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 策略为 `never`。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -24,19 +24,21 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 **从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 -**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。文件限制继续经由 fs 栈生效。 +**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。 + +**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决:shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。 **交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 ## 后果 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 策略为 `never`、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 - 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 -- 权限切换器离开 Windows 清单;会话权限事实通过批准服务与 sandbox 策略固定组合默认值。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与 `never` approval 策略)。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index a47ac9cbc7..0cc7f47a1b 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: a4f220c956f7f67d7810827d4488d54a071ad3d6 -README.zh.md: eaf96f8c0640481163e7a1c1533a07e35ecc294f +README.md: 81932442fe9e2edac82bda88bbe28ce56fd8b6c1 +README.zh.md: b0af307b7655a8729a8face324a2750e2e480651 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index a4f220c956..81932442fe 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash executor/tool and the permission stack (dsh-permission requires a confining executor), and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`). The launcher applies it between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), and the sandbox/fs-policy stack — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`, with the approval policy set to `never`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **Windows loses the permission switcher** — `dsh-permission` hard-requires a confining `ctx.bash` executor, so the Windows platform layer disables `permission`/`ui-permission` with the bash stack. The fs tools keep the sandbox policy and the approval service, so file confinement and escalation still apply on Windows. +- **Windows has no sandbox** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack: `sandbox`/`sandbox-policy`/`fs-sandbox` are disabled, `dsh-fs-local` provides `ctx.fs`, the permission switcher leaves the roster, and the approval policy is `never`. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index eaf96f8c06..b0af307b76 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 执行器/工具与权限栈(dsh-permission 要求有限权能力的执行器),并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)与 sandbox/fs 策略栈——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`,`approval` 策略设为 `never`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 上失去权限切换器**:`dsh-permission` 硬性要求有限权能力的 `ctx.bash` 执行器,因此 Windows 平台层随 bash 栈一起禁用 `permission`/`ui-permission`。fs 工具保留 sandbox 策略与批准服务,Windows 上的文件限制与升级仍然生效。 +- **Windows 上没有沙箱**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`,权限切换器离开清单,`approval` 策略为 `never`。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index cb2b7178f7..d2f93e2bd3 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 24ee2a1ba3..6adcab1715 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -13,13 +13,60 @@ import { entryListSchema } from '@cordisjs/plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dsh?: { bundle?: { patch?: string } } } + const manifest = JSON.parse( + readFileSync(resolve(root, 'package.json'), 'utf8'), + ) as { dsh?: { bundle?: { patch?: string } } } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), { schema: entryListSchema }) + const parsed = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) expect(Array.isArray(parsed)).toBe(true) // The base layer is one insert list over the empty profile root. - const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(patch => patch.insert ?? []) + const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap( + patch => patch.insert ?? [], + ) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) }) + + it('ships the Windows platform layer as the documented danger-full-access roster', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const parsed = yaml.load( + readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), + { schema: entryListSchema }, + ) as { + id?: string + disabled?: boolean + insert?: { id?: string; name?: string }[] + config?: { policy?: string } + }[] + const disables = parsed + .filter(patch => patch.disabled === true) + .map(patch => patch.id) + // The POSIX-only sandboxed stacks leave the Windows roster as one unit: + // shell (bash-sandbox/tool-bash), the permission switcher it requires, + // and the fs/sandbox policy stack whose OS runners do not exist on win32. + expect(disables).toEqual( + expect.arrayContaining([ + 'bash-sandbox', + 'tool-bash', + 'permission', + 'ui-permission', + 'sandbox', + 'sandbox-policy', + 'fs-sandbox', + ]), + ) + const inserted = parsed + .flatMap(patch => patch.insert ?? []) + .map(row => row.id) + expect(inserted).toEqual( + expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), + ) + // Full danger-full-access degradation: no approval prompts on Windows. + expect(parsed.find(patch => patch.id === 'approval')?.config).toEqual({ + policy: 'never', + }) + }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 59d5e81582..50dbe8d9e5 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,18 +1,16 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers, replacing the -# POSIX-only bash stack with the PowerShell stack. The launcher reads THIS -# file from the base bundle package (never through dsh.bundle.patch — that -# field names the one universal layer). A Windows host that prefers bash -# overrides the rows here through its profile or home cordis.patch.yml. -# -# Windows hosts cannot run the shipped bash executor (POSIX-only: hardcoded -# `bash -c` argv and process-group semantics), so the shipped Windows -# experience is PowerShell-native: pwsh-local backs `ctx.bash` and tool-pwsh -# is the model-facing shell tool. dsh-permission requires a confining -# executor (its presets bundle a sandbox mode the unconfined pwsh executor -# cannot honor), so the permission service and its client knob leave the -# Windows roster with the bash stack; the fs tools keep the sandbox policy -# and the approval service, so file confinement and escalation still apply. +# hosts, between the bundle layers and the user layers. Windows cannot run +# the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack +# for the PowerShell stack AND drops the sandbox entirely: no OS runner +# exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any policy +# would be theater — the unconfined shell could bypass fs-only path rules +# with one command. Windows therefore degrades to danger-full-access: +# unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher +# (dsh-permission requires a confining executor), approval policy `never`. +# The launcher reads THIS file from the base bundle package (never through +# dsh.bundle.patch — that field names the one universal layer). A Windows +# host that prefers bash or confinement overrides these rows through its +# profile or home cordis.patch.yml. - id: bash-sandbox disabled: true @@ -26,9 +24,25 @@ - id: ui-permission disabled: true +- id: sandbox + disabled: true + +- id: sandbox-policy + disabled: true + +- id: fs-sandbox + disabled: true + +- id: approval + config: + policy: never + - insert: - id: pwsh-local name: '@deepseek-ai/dsh-pwsh-local' - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' + + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' From 73c6f351714762ae57f10317ff7687da6bbe0b7e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 01:41:32 +0800 Subject: [PATCH 04/81] fix(bundle): drop the approval service from the Windows layer entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows layer previously kept the approval service with policy 'never', which made the approval plugin inject 'Approval prompts are disabled in this session: actions that require approval are rejected automatically' into the model context. On Windows nothing asks for approval — the escalation surfaces (sandbox_permissions) do not exist — so the sentence described a rejection surface that is not there. The layer now disables the approval row too: the service is absent, the model is never told approval exists, and the danger-full-access degradation is complete. base.spec.ts pins approval among the Windows disables; the Agent Note and bundle README record the absent service. --- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../feature/2026-08-01-windows-pwsh-default.md | 6 +++--- .../feature/2026-08-01-windows-pwsh-default.zh.md | 6 +++--- packages/bundle/base/README.i18n.yaml | 4 ++-- packages/bundle/base/README.md | 4 ++-- packages/bundle/base/README.zh.md | 4 ++-- packages/bundle/base/tests/base.spec.ts | 11 ++++++----- packages/bundle/base/windows.cordis.patch.yml | 15 ++++++++------- pnpm-lock.yaml | 3 +++ 9 files changed, 31 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 711699aa0b..7ab92d8199 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: ceffd825c2316841dfbe01f4510243ba8748e500 -2026-08-01-windows-pwsh-default.zh.md: ea8058878fe268bdf1317620c853b8279c543532 +2026-08-01-windows-pwsh-default.md: 75a27b5140abacf77b5f20028752a4903a50eaec +2026-08-01-windows-pwsh-default.zh.md: 8b940f1973835f8143bde05b37eba6f00e27f8f4 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index ceffd825c2..75a27b5140 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,7 +12,7 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` policy is `never`. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. - **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. - **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. @@ -33,12 +33,12 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w ## Consequences - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). -- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval policy is `never`, and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. +- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. - Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the `never` approval policy). +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the absent approval service). - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index ea8058878f..8b940f1973 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,7 +12,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 策略为 `never`。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 - **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 - **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 @@ -33,12 +33,12 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 ## 后果 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 -- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 策略为 `never`、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 +- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 - 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与 `never` approval 策略)。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与缺席的 approval 服务)。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 0cc7f47a1b..687c86ae0b 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 81932442fe9e2edac82bda88bbe28ce56fd8b6c1 -README.zh.md: b0af307b7655a8729a8face324a2750e2e480651 +README.md: 84e7f43aaa6cfead0ff28e30063e71062d358f0a +README.zh.md: 30c7c259f8893ebd34da6cab362dedac067ce099 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 81932442fe..84e7f43aaa 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), and the sandbox/fs-policy stack — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`, with the approval policy set to `never`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **Windows has no sandbox** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack: `sandbox`/`sandbox-policy`/`fs-sandbox` are disabled, `dsh-fs-local` provides `ctx.fs`, the permission switcher leaves the roster, and the approval policy is `never`. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. +- **Windows has no sandbox and no approval** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack (`sandbox`/`sandbox-policy`/`fs-sandbox` disabled, `dsh-fs-local` provides `ctx.fs`), the permission switcher leaves the roster, and the approval service is disabled — nothing on Windows asks for approval, so the model is never told approval exists. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index b0af307b76..30c7c259f8 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)与 sandbox/fs 策略栈——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`,`approval` 策略设为 `never`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 上没有沙箱**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`,权限切换器离开清单,`approval` 策略为 `never`。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 +- **Windows 上没有沙箱、没有 approval**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`——权限切换器离开清单,approval 服务也被禁用:Windows 上没有任何动作需要审批,模型也不会被告知审批存在。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 6adcab1715..dc18224dda 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -46,7 +46,9 @@ describe('dsh-base bundle', () => { .map(patch => patch.id) // The POSIX-only sandboxed stacks leave the Windows roster as one unit: // shell (bash-sandbox/tool-bash), the permission switcher it requires, - // and the fs/sandbox policy stack whose OS runners do not exist on win32. + // the fs/sandbox policy stack whose OS runners do not exist on win32, + // and the approval service — nothing on Windows asks for approval, so + // the model is never told approval exists or that asks auto-reject. expect(disables).toEqual( expect.arrayContaining([ 'bash-sandbox', @@ -56,6 +58,7 @@ describe('dsh-base bundle', () => { 'sandbox', 'sandbox-policy', 'fs-sandbox', + 'approval', ]), ) const inserted = parsed @@ -64,9 +67,7 @@ describe('dsh-base bundle', () => { expect(inserted).toEqual( expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), ) - // Full danger-full-access degradation: no approval prompts on Windows. - expect(parsed.find(patch => patch.id === 'approval')?.config).toEqual({ - policy: 'never', - }) + // Full danger-full-access degradation: no approval surface at all. + expect(parsed.find(patch => patch.id === 'approval')?.config).toBeUndefined() }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 50dbe8d9e5..439861f922 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,12 +1,14 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 # hosts, between the bundle layers and the user layers. Windows cannot run # the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack -# for the PowerShell stack AND drops the sandbox entirely: no OS runner -# exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any policy -# would be theater — the unconfined shell could bypass fs-only path rules -# with one command. Windows therefore degrades to danger-full-access: +# for the PowerShell stack AND drops the whole permission surface: no OS +# runner exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any +# policy would be theater — the unconfined shell could bypass fs-only path +# rules with one command. Windows therefore degrades to danger-full-access: # unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher -# (dsh-permission requires a confining executor), approval policy `never`. +# (dsh-permission requires a confining executor), and no approval service — +# nothing in the roster asks for approval, and the model is never told +# approval exists or that requests are auto-rejected. # The launcher reads THIS file from the base bundle package (never through # dsh.bundle.patch — that field names the one universal layer). A Windows # host that prefers bash or confinement overrides these rows through its @@ -34,8 +36,7 @@ disabled: true - id: approval - config: - policy: never + disabled: true - insert: - id: pwsh-local diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 453109bcae..e99ec0f602 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,6 +898,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy From 653e22bc50c752aeda97b42dd05ad42ec98936ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 11:55:46 +0800 Subject: [PATCH 05/81] test(web): cover empty-draft queue flush with a keyless replay scenario Also pin the queue-item-not-found convergence arm through the hub and document the repeated-trigger contract on steerQueue. --- .../snapshots/steer-all/mid-steer.expected.md | 36 ++++++ .../snapshots/steer-all/replay.override.json | 47 ++++++++ .../snapshots/steer-all/settled.expected.md | 51 +++++++++ apps/web/tests/steering.e2e.ts | 107 ++++++++++++++++++ .../ui-conversation/src/client/input/hub.ts | 5 +- .../tests/service-orchestration.spec.ts | 13 +++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 apps/web/tests/snapshots/steer-all/mid-steer.expected.md create mode 100644 apps/web/tests/snapshots/steer-all/replay.override.json create mode 100644 apps/web/tests/snapshots/steer-all/settled.expected.md diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md new file mode 100644 index 0000000000..3546fe018f --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -0,0 +1,36 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think": + - img + - img + - text: Think +- status: Deep diving... +- text: "Interjection Interjection: include the word BANANA in your final reply." +- button "Copy": + - img +- text: "Interjection Interjection: include the word ORANGE in your final reply." +- button "Copy": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/steer-all/replay.override.json b/apps/web/tests/snapshots/steer-all/replay.override.json new file mode 100644 index 0000000000..6a1c133faf --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/replay.override.json @@ -0,0 +1,47 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "reasoning" }, + { "type": "reasoning-delta", "index": 0, "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { + "type": "tool-call-delta", + "index": 1, + "id": "call_00_steer_all", + "name": "ask_user_question", + "argumentsDelta": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." + } + }, + { + "type": "block-end", + "index": 1, + "block": { + "type": "tool-call", + "id": "call_00_steer_all", + "name": "ask_user_question", + "arguments": "{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" + } + }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "Got it: BANANA and ORANGE." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "Got it: BANANA and ORANGE." } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md new file mode 100644 index 0000000000..aad8000181 --- /dev/null +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -0,0 +1,51 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the ask_user_question tool to" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: 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": + - img + - img + - text: Ask question 1/1 answered +- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: "Available only on the last message of a completed turn Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- paragraph: "Got it: BANANA and ORANGE." +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 6c96f9b6aa..182f892d54 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -34,6 +34,18 @@ const REPLAY_PACE_MS = 100 const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' const STEER = 'Interjection: include the word BANANA in your final reply.' +// Empty-draft flush scenario: an override-only fixture. The whole-script +// replacement answers both model calls of a FRESH session (no recorded +// session.jsonl exists — call 0 keeps the turn open with a question-tool +// call, call 1 is the reply after both steerings drain). +const STEER_ALL_DIR = fileURLToPath(new URL('./snapshots/steer-all', import.meta.url)) +const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl') +const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json') +const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md') +const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md') +const STEER_ONE = 'Interjection: include the word BANANA in your final reply.' +const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.' + /** Concatenated assistant text deltas — the model-visible reply body. */ function assistantText(events: SessionEvent[]): string { return events @@ -278,3 +290,98 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) }) + +describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + // The scenario boots a fresh session against the override-only fixture; + // the replay.override.json sidecar replaces the derived script, so the + // (deliberately absent) session.jsonl is never read. + scaffold = await launchWebScaffold({ + replayFixture: STEER_ALL_FIXTURE, + replayOverride: STEER_ALL_OVERRIDE, + paceMs: REPLAY_PACE_MS, + }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-steer-all')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(30_000) + + // Call 0 streams a question-tool call; the fills must land inside the + // first replay window, before the question composer replaces the textarea. + await input.fill(PROMPT) + await input.press('Enter') + await input.fill(STEER_ONE) + await input.press('Enter') + await input.fill(STEER_TWO) + await input.press('Enter') + const dock = page.locator('[data-queue-dock]') + // Both messages queued: the two-row dock shows a collapsed count header, + // and Playwright text matching skips the hidden rows — expand the list, + // then assert each row's content. + await dock.getByText('2 queued messages').waitFor({ timeout: 10_000 }) + await dock.getByRole('button').click() + await dock.getByText(STEER_ONE, { exact: true }).waitFor({ timeout: 10_000 }) + await dock.getByText(STEER_TWO, { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + + // Empty draft + Cmd+Enter: both queued rows steer in FIFO order, the dock + // empties, and the pending steering renders at the conversation tail. + await input.press('Meta+Enter') + await expect.poll( + () => page.locator('[data-pending-steering]').filter({ hasText: /BANANA|ORANGE/ }).count(), + { timeout: 10_000 }, + ).toBe(2) + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) + + // Answer the question; the step closes, the loop drains both steerings + // into one next-step request, and the final reply obeys both markers. + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + const first = claimedMessages(sessionEvents, STEER_ONE) + const second = claimedMessages(sessionEvents, STEER_TWO) + expect(first).toHaveLength(1) + expect(second).toHaveLength(1) + expect(assistantText(sessionEvents)).toContain('BANANA') + expect(assistantText(sessionEvents)).toContain('ORANGE') + await expect.poll(() => page.getByText(STEER_ONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll(() => page.getByText(STEER_TWO, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await page.locator('[data-pending-steering]').count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 200_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(STEER_ALL_DIR, [ + 'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md', + ]) + }) +}) diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index e9e58035d2..e0dbb715a1 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -152,7 +152,10 @@ export class InputHub implements InputService { * order — the same strict-steer operation as the queue dock's per-row * button. A turn closing mid-way (`steer-unavailable`) or a row already * claimed by the agent (`queue-item-not-found`) converges silently, while a - * genuine failure surfaces as one composer notice. + * genuine failure surfaces as one composer notice. Repeated triggers + * (e.g. two rapid empty-draft chords) rely on that `queue-item-not-found` + * convergence: the snapshot may still list a row the host already steered, + * and the duplicate strict steer is a silent no-op. * @param session - the addressed host session. * @param shell - the resident shell (notice outlet). */ diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 126cb65a47..c8a7be4dc9 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -125,12 +125,25 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => { await b.runtime.sessions.updateSnapshot('s1', (draft) => { draft.queue = [row('q-1'), row('q-2')] }) + // The turn closes before the second row: the flush stops, silently. b.updateQueue.mockResolvedValueOnce({ ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, } as never) b.shell.steerQueue() await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) }) expect(b.shell.notices.getSnapshot()).toBeNull() + + // A row the host already claimed (e.g. a repeated empty-draft chord): + // the duplicate strict steer is a silent no-op. + await b.runtime.sessions.updateSnapshot('s1', (draft) => { + draft.queue = [row('q-3')] + }) + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + } as never) + b.shell.steerQueue() + await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) }) + expect(b.shell.notices.getSnapshot()).toBeNull() await b.runtime.dispose() }) From 9597ff9bf7a470d368c0b9848a309f01ced19cee Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 7 Aug 2026 11:54:45 +0800 Subject: [PATCH 06/81] feat(web): advertise whole-queue steering shortcut --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 ++-- .../2026-08-06-web-queue-steer-all-gesture.md | 5 +++- ...26-08-06-web-queue-steer-all-gesture.zh.md | 5 +++- .../queue-actions/collapsed.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../queue-actions/layout.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/InputBar.tsx | 9 ++++--- .../ui-conversation/tests/input-bar.spec.tsx | 24 +++++++++++++++++++ 13 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index 5b51304fe6..0e3cc06a83 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: e65089fca763a4226a40b090f3a5f8f56284b9bc -2026-08-06-web-queue-steer-all-gesture.zh.md: abccf60a9da54ba1c639e93c63107d97913d9d66 +2026-08-06-web-queue-steer-all-gesture.md: c4452c16fe01f876aff72715a7d1056a09ae1e1f +2026-08-06-web-queue-steer-all-gesture.zh.md: 27e147304eec0dff8ba6209d4de3f312375fe4df diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index e65089fca7..c4452c16fe 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -14,9 +14,11 @@ Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inb The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. +The same computed availability gate drives discovery: while the draft is empty, the input is unlocked, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence. + ## Consequences -One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The cost is a presentation-layer branch that must stay in sync with the dock's gating window (running, non-subagent) — the hub re-checks the snapshot at execution time, so the gate is advisory and the host remains authoritative. +One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The gesture and its placeholder share one presentation-layer gate, while the hub re-checks the snapshot at execution time, so the client gate remains advisory and the host remains authoritative. ## Related @@ -28,3 +30,4 @@ The per-row 插话发送 action and its strict-steer boundary are owned by [Stee - **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence. - **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO. - **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing. +- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly. diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index abccf60a9d..27e147304e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -14,9 +14,11 @@ Status: implemented 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先。 + ## Consequences -一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。代价是呈现层多了一个分支,必须与 dock 的门控窗口(running、非 subagent)保持同步——hub 在执行时重新读取快照,所以该门控只是建议性的,host 仍是权威。 +一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。手势及其 placeholder 共用一个呈现层门控;hub 在执行时会重新读取快照,因此客户端门控仍只是建议性的,host 仍是权威。 ## Related @@ -28,3 +30,4 @@ Status: implemented - **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。 - **并发触发所有行。** 已拒绝:host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。 - **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer,中途关闭静默收敛——协议改动没有收益。 +- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop,这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。 diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index cdde5d8790..3dc7f577af 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -17,7 +17,7 @@ - paragraph: partial - status: Deep diving... - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 8bfd2f964d..935da27c5c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -33,7 +33,7 @@ - tooltip "Save queued message" - button "Cancel editing": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 7370a15264..1f9c239c60 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -29,7 +29,7 @@ - button "Clear goal": - img - button "2 queued messages" -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 48b714a88c..65c455975d 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -26,7 +26,7 @@ - img - button "Steer queued message": - img -- textbox "Message the agent" +- textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 974f8a65b0..d447dbba84 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 31ae990d2d97757a4d7ea06b323e2131f764b5f7 -README.zh.md: 0f21da2f48dbfd6673fb02d6719390bec872afeb +README.md: c3b5470501cbbe08054c177436a211bc02bb077b +README.zh.md: 9bb19a6f04a7ac4aebc8af0bff0014bbdfc0b755 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 31ae990d2d..c3b5470501 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The todo surfaces are two registrations over that shape, both using slot declara The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. With an empty draft, Cmd/Ctrl+Enter instead steers every still-pending queued message into the running turn in FIFO order (the dock's per-row strict-steer action applied to the whole queue); plain Enter with an empty draft remains a no-op. While this whole-queue gesture is available, the textarea placeholder advertises it; a placeholder supplied by the owning surface still takes precedence. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0f21da2f48..9bb19a6f04 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注 Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。草稿为空时,Cmd/Ctrl+Enter 改为按 FIFO 顺序把仍在排队的消息全部插话进运行中的轮次(把 dock 的逐条严格 steer 操作应用于整个队列);空草稿 + 普通 Enter 仍是无操作。这个整队列手势可用时,文本框 placeholder 会提示该手势;owner 提供的 placeholder 仍然优先。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9ba5ed3876..9fd3b21b74 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,6 +22,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', + 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -162,6 +163,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', + 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'context.aria': '{percent} of context used', 'context.used': 'of context used', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7a85ef7e08..5303c7b7d9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,6 +89,8 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + const canSteerQueue = !locked && !machineBusy && empty && running && subagent === null + && input.queue.some(row => row.placement === 'queued') // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does @@ -253,8 +255,7 @@ export function InputBar({ // still-pending queued message into the running turn (the dock's per-row // steer button applied to the whole queue). Steering needs the same // window as the per-row button: a running ordinary session. - if (accelerated && empty && running && subagent === null - && input.queue.some(row => row.placement === 'queued')) { + if (accelerated && canSteerQueue) { keyboard.steerQueue() return } @@ -485,7 +486,9 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + : canSteerQueue + ? t('input.steerQueueShortcut') + : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 67a9b48e90..f8127fa892 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -169,6 +169,30 @@ function bench(over?: BenchOptions) { } describe('Enter semantics', () => { + it('advertises the empty-draft whole-queue steering gesture when it is available', () => { + const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() }) + expect(textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { + expect(bench({ running: true }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ queue: [row('q-1')] }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + subagent: { + address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }, + }).textarea.placeholder).toBe('给智能体发消息') + expect(bench({ + running: true, + queue: [row('q-1')], + placeholder: '上层指定提示', + }).textarea.placeholder).toBe('上层指定提示') + }) + it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) From 1e759608f6c7717eab14ffccd39f37be8c8324d4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 13:48:33 +0800 Subject: [PATCH 07/81] test(web): refresh steer-all goldens against merged master UI --- .../tests/snapshots/steer-all/mid-steer.expected.md | 3 --- apps/web/tests/snapshots/steer-all/settled.expected.md | 10 +--------- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 3546fe018f..65f22f2140 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index aad8000181..a61f57572e 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -7,9 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img @@ -25,14 +22,9 @@ - text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: "Available only on the last message of a completed turn Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - paragraph: "Got it: BANANA and ORANGE." - button "Copy": - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5ad20c3fc..0734355828 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: a75f25d8669cd688795842a655106e0e27bb7173 -README.zh.md: f0d744c31020730857d210d75749b907dbffca08 +README.md: 8d5034a84edd56b7c9b411f6e281d1c3b7733a82 +README.zh.md: 309bf54c2b1759c33f21ada33184e2a118b2f00f From 4ede2798fead9f69387a799ec372be1eade4beaf Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 14:20:14 +0800 Subject: [PATCH 08/81] =?UTF-8?q?fix(web):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20placeholder=20key=20family,=20menu-open=20gate,=20plan=20pre?= =?UTF-8?q?cedence,=20note=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-06-web-queue-steer-all-gesture.i18n.yaml | 4 +-- .../2026-08-06-web-queue-steer-all-gesture.md | 2 +- ...26-08-06-web-queue-steer-all-gesture.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 4 +-- .../src/client/skeleton/InputBar.tsx | 7 +++-- .../ui-conversation/tests/input-bar.spec.tsx | 27 +++++++++++++++++++ 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml index 0e3cc06a83..1935c6de2e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md -2026-08-06-web-queue-steer-all-gesture.md: c4452c16fe01f876aff72715a7d1056a09ae1e1f -2026-08-06-web-queue-steer-all-gesture.zh.md: 27e147304eec0dff8ba6209d4de3f312375fe4df +2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76 +2026-08-06-web-queue-steer-all-gesture.zh.md: fb36852f66a86408af12df59001230b2024eccaf diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md index c4452c16fe..e546f68647 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md @@ -14,7 +14,7 @@ Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inb The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter. -The same computed availability gate drives discovery: while the draft is empty, the input is unlocked, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence. +The same computed availability gate drives discovery: while the draft is empty, the input is unlocked and not in a transient machine lock, the command menu is closed, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence, and the steer hint deliberately outranks the plan-mode placeholder while available (the gesture genuinely works in that window). ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md index 27e147304e..fb36852f66 100644 --- a/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.zh.md @@ -14,7 +14,7 @@ Status: implemented 该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer);草稿内容优先于队列(加速 Enter 只插话当前草稿);idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。 -同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先。 +同一套计算得出的可用性门控也负责提示该手势:当草稿为空、输入框未锁定且不处于瞬态机器锁(adjudicating/submitting)、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder(该窗口内手势确实可用)。 ## Consequences diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9fd3b21b74..20aab2cfda 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -22,7 +22,7 @@ export const zh = { 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', - 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter 插话发送全部排队消息', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', @@ -163,7 +163,7 @@ export const en = { 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', - 'input.steerQueueShortcut': 'Cmd/Ctrl+Enter steers all queued messages', + 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', 'context.aria': '{percent} of context used', 'context.used': 'of context used', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5303c7b7d9..e326c8e523 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,7 +89,7 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' - const canSteerQueue = !locked && !machineBusy && empty && running && subagent === null + const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') // Scroll the draft scrollport the minimum that brings `caret` into view — the @@ -486,8 +486,11 @@ export function InputBar({ data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. : canSteerQueue - ? t('input.steerQueueShortcut') + ? t('placeholder.steerQueue') : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f8127fa892..4ae6626b14 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -191,6 +191,33 @@ describe('Enter semantics', () => { queue: [row('q-1')], placeholder: '上层指定提示', }).textarea.placeholder).toBe('上层指定提示') + // The command menu owns Enter while open: neither the hint nor the + // gesture may claim the chord. + expect(bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + }).textarea.placeholder).toBe('给智能体发消息') + // The steer hint intentionally outranks the plan placeholder: while it + // shows, the whole-queue gesture is genuinely available in plan mode. + expect(bench({ + running: true, + queue: [row('q-1')], + plan: { active: true, pending: false }, + }).textarea.placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') + }) + + it('an open command menu withholds the whole-queue steering gesture', () => { + const steerQueue = vi.fn() + const { textarea, sink } = bench({ + running: true, + queue: [row('q-1')], + commandMenuOpen: true, + steerQueue, + }) + fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) + expect(steerQueue).not.toHaveBeenCalled() + expect(sink).not.toHaveBeenCalled() }) it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { From a7e43d4346647546ea0f489566f130e45495d27e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:26:50 +0800 Subject: [PATCH 09/81] refactor: drop the create-by-name workspace route The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's workspaceRoot config/default and the mkdir branch, the client seam that carried the name (WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web --workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as workspace.rename's duplicate-title error. --- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 2 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/args.ts | 8 +- apps/cli/src/web.ts | 10 +-- apps/cli/tests/args.spec.ts | 4 +- docs/config-catalog.md | 6 +- packages/bundle/web-app/cordis.patch.yml | 2 +- .../client/connection/src/client/fixture.ts | 9 +- .../client/connection/tests/fixture.spec.ts | 21 ++--- .../runtime/src/client/contract/workspaces.ts | 6 +- .../runtime/src/client/workspaces/manager.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 6 +- .../src/client/workspaces/workspace.ts | 3 +- .../runtime/tests/workspaces-service.spec.ts | 6 +- .../client/test-runtime/src/workspaces.ts | 11 ++- .../test-runtime/tests/runtime.spec.tsx | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 74 +--------------- .../host/apiproxy/src/api/workspace.schema.ts | 10 +-- packages/host/apiproxy/src/api/workspace.ts | 19 ++--- packages/host/apiproxy/src/index.ts | 12 +-- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 ++--- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 2 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 1 - .../apiproxy/tests/api-proxy-models.spec.ts | 4 +- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +-- .../tests/api-proxy-workspace.spec.ts | 85 +++++++++---------- .../apiproxy/tests/client-handler.spec.ts | 4 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 6 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- 44 files changed, 145 insertions(+), 252 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 1c0cc5644d..691511d76b 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 5d002265b5eb1178bb1dbc7bd17f8b364d9b9856 -2026-07-31-one-route-to-add-a-workspace.zh.md: 0a59d3a505eb921b4ec980abaefedfcad8a3c294 +2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 +2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 5d002265b5..853d641e0a 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -27,7 +27,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i ## Wire and CLI residue -The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. +Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. ## Testing diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 0a59d3a505..6a2884d751 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -27,7 +27,7 @@ Status: implemented ## Wire and CLI residue -Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 +本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 ## Testing diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..db6a4c6dbe 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 9adf7e4b238a96c5497c14709ba7ddc08eff13af +README.zh.md: d4f2c607f1b6067c0d936761cb86301950597e1d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..9adf7e4b23 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -37,7 +37,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..d4f2c607f1 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -37,7 +37,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..349a127916 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,7 +40,6 @@ interface WebInvocation { host?: string port?: number dev: boolean - workspaceRoot?: string /** Extra authorities for the /api browser-trust fence. */ trustedHosts?: string[] } @@ -62,7 +61,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean @@ -152,7 +150,6 @@ Examples: .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') @@ -172,8 +169,8 @@ Examples: // dropping them would print a tree that differs from the same // invocation's boot. if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return @@ -187,7 +184,6 @@ Examples: ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 64051416c4..ca6a53c3e0 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--workspace-root/ - * --trusted-host`), each flag becoming a patch over the composed profile + * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag + * becoming a patch over the composed profile * tree. All web runtime glue (dist serving, prompt section, URL line) lives * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives * flag patches and the LAN-trust snapshot. @@ -58,7 +58,6 @@ export interface WebFlags { host?: string port?: number dev: boolean - workspaceRoot?: string trustedHosts?: string[] } @@ -82,7 +81,6 @@ function deriveWebFlagPatches( } if (flags.host !== undefined) put('webserver', 'host', flags.host) if (flags.port !== undefined) put('webserver', 'port', flags.port) - if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) if (trustedHosts.length > 0) { @@ -118,8 +116,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { } /** - * Serve the browser UI from the web profile. Host/port/workspace-root flags - * are passed through only when given (absent, the composed profile values + * Serve the browser UI from the web profile. Host/port flags are passed + * through only when given (absent, the composed profile values * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..66e1a0db40 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -29,8 +29,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) - expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 13ebcc5b32..edb1576689 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -577,18 +577,16 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } ``` -Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 624e9e37af..ddb5c97b7b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -4,7 +4,7 @@ # # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--workspace-root/--trusted-host into further patches over these rows +# --dev/--trusted-host into further patches over these rows # (`--dev` inserts the dsh-client-hmr row). # ── surface-specific values the base deliberately omits ───────────────────── diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..f92a0da870 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2122,15 +2122,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { archivedSessionIds: [...archivedSessionIds], }), create: (request) => { - const { path, name } = request.payload - const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` - const existing = workspaces.find(w => w.path === target) + const { path } = request.payload + const existing = workspaces.find(w => w.path === path) if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) const now = new Date().toISOString() const created: WorkspaceView = { workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path: target, - title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + path, + title: path.split('/').filter(Boolean).at(-1) ?? path, sessionIds: [], createdAt: now, updatedAt: now, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index f608190936..5b9824fcde 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -546,7 +546,7 @@ describe('createFixtureApi', () => { } })() await new Promise(resolve => setTimeout(resolve, 10)) - const created = await api.workspace.create(req({ name: 'nova' })) + const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.created).toBe(true) expect(created.result.value.workspace).toMatchObject({ @@ -554,16 +554,7 @@ describe('createFixtureApi', () => { }) await consuming expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) - // path spelling falls back to the basename when no title/name rides along. - const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) - if (!pathOnly.result.ok) throw new Error('pathOnly failed') - expect(pathOnly.result.value.workspace.title).toBe('base') - // Degenerate spellings reach the impl unfiltered (the fixture carrier has - // no schema gate): both-absent falls back to the bucket dir, and a - // basename-less path serves as its own title. - const bare = await api.workspace.create(req({})) - if (!bare.result.ok) throw new Error('bare failed') - expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') expect(rootPath.result.value.workspace.title).toBe('/') @@ -584,7 +575,7 @@ describe('createFixtureApi', () => { const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) - await api.workspace.create(req({ name: 'occupied' })) + await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) @@ -722,7 +713,7 @@ describe('createFixtureApi', () => { expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) - const made = await api.workspace.create(req({ name: 'nova' })) + const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) @@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ name: 'via-client' }) + const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId @@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) const client = new FixtureApiClient() await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ name: 'query-workspace' }) + const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..ad896bbdaf 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -27,11 +27,11 @@ export interface IWorkspaces { */ startSession(workspaceId?: WorkspaceId): void /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - create(input: { name: string } | { path: string }): Promise + create(input: { path: string }): Promise /** * Open the Host's native directory picker. * @returns the selected path, or null when the user cancelled. diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ccf0c46fe1..df3aa8fe28 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -120,7 +120,7 @@ export class WorkspaceManager { /** * Create or resolve a real Workspace, then publish its returned snapshot * without waiting for the changed frame. - * @param input - name under workspaceRoot or an existing absolute path. + * @param input - the existing absolute path to adopt. * @returns the wire result. */ async create(input: WorkspaceCreateInput): Promise> { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 69910fd0c4..c0f71b92db 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces { } /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { const result = await this.manager.create(input) if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index afa4dd65b6..f6657c7053 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' /** Host input retained by a local Workspace until materialization succeeds. */ -export type WorkspaceCreateInput = { name: string } | { path: string } +export type WorkspaceCreateInput = { path: string } /** Observable state of a client-local Workspace intent. */ export interface WorkspaceIntentSnapshot { @@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot { } function intentName(input: WorkspaceCreateInput): string { - if ('name' in input) return input.name const trimmed = input.path.replace(/[\\/]+$/, '') return trimmed.split(/[\\/]/).pop() ?? input.path } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 832a1ff71a..dd38a3119c 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -59,7 +59,7 @@ describe('WorkspaceManager', () => { expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) }) - it('creates by name/path, prepends a new row, and folds failures', async () => { + it('creates by path, prepends a new row, and folds failures', async () => { const api = new FakeApiClient() const manager = new WorkspaceManager(api) api.onWorkspaceCreate = payload => Promise.resolve(ok({ @@ -67,8 +67,8 @@ describe('WorkspaceManager', () => { created: true, payload, } as never)) - await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }]) expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..9e1061ec8c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces { /** * Create a Workspace (recorded). The default echoes a view derived from * the input; stub for failure or list-coupled flows. - * @param input - exactly one Host create spelling. + * @param input - the Host create payload. * @returns the created Workspace view. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { this.calls.push({ method: 'create', args: [input] }) const stub = this.stubs.get('create') if (stub !== undefined) return await (stub(input) as Promise) - const title = 'name' in input ? input.name : input.path return { - workspaceId: `ws-${title}` as WorkspaceId, - title, - path: 'path' in input ? input.path : `/${input.name}`, + workspaceId: `ws-${input.path}` as WorkspaceId, + title: input.path, + path: input.path, sessionIds: [], } as unknown as WorkspaceView } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f92d21b4b5..d0b3d75d52 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -559,8 +559,8 @@ describe('workspaces action face', () => { it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { const runtime = await SlotTestRuntime.create() const ws = runtime.workspaces - const created = await ws.create({ name: 'alpha' }) - expect(created.title).toBe('alpha') + const created = await ws.create({ path: '/tmp/alpha' }) + expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') await expect(ws.pickDirectory()).resolves.toBeNull() @@ -584,7 +584,7 @@ describe('workspaces action face', () => { ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) - expect((await ws.create({ name: 'y' })).title).toBe('X') + expect((await ws.create({ path: '/y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..97aad6680c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 -README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 +README.md: 1caf9f2ee61fbf36a18b18ff2d1e7e230ee4b7f6 +README.zh.md: c42312bb9972c4c5b02ec4dbc5c7d4402921a9c1 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..1caf9f2ee6 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). ## Contract layer (`/api`) @@ -24,7 +24,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..c42312bb99 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 ## 契约层(`/api`) @@ -24,7 +24,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..5ad2318945 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' @@ -333,8 +332,6 @@ export interface ApiProxyDefaults { model: string /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string - /** Parent directory for name-created workspaces. */ - workspaceRoot: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ @@ -668,9 +665,6 @@ class SessionCwdConflict extends Error { } } -/** Host failed before the registry could adopt a name-created directory. */ -class WorkspaceDirectoryCreationError extends Error {} - /** An explicit Host naming operation would duplicate another Workspace title. */ class WorkspaceNameConflictError extends Error { constructor(readonly workspaceName: string) { @@ -1183,29 +1177,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace( - path: string, - title: string | undefined, - rejectExistingName = false, - createDirectory = false, - ): Promise<{ workspace: Workspace; created: boolean }> { + function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { const operation = workspaceCreationChain.then(async () => { - if (rejectExistingName && title !== undefined - && ctx.workspace.list().some(workspace => workspace.title === title)) { - throw new WorkspaceNameConflictError(title) - } - if (createDirectory) { - try { - await mkdir(path, { recursive: true }) - } catch (error: unknown) { - throw new WorkspaceDirectoryCreationError( - `failed to create workspace directory "${path}": ${String(error)}`, - ) - } - } const existing = await ctx.workspace.resolveByPath(path) if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspace.create(path, title), created: true } + return { workspace: await ctx.workspace.create(path), created: true } }) workspaceCreationChain = operation.then(() => undefined, () => undefined) return operation @@ -2035,54 +2011,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - // Exactly one of path/name arrives (schema refine). Existing-folder - // adoption reuses its canonical path; create-by-name rejects a name - // already present in the registry. - // TODO: the create-by-name branch lost its last product consumer when - // the Web picker collapsed onto the directory flow - // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md). - // Delete it with the wire schema's `name` member, this - // `defaults.workspaceRoot`, the client seam that carried the name - // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, - // `intentName`'s name branch, the manager's "name under workspaceRoot" - // contract), and the `dsh web --workspace-root` flag plus its apps/cli - // README lines, which exist only to feed it. async create(request) { - const { payload } = request - let path: string - if (payload.name !== undefined) { - const name = payload.name.trim() - if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { - return err(request, { - code: 'workspace-invalid-path', - message: `workspace name must be one non-empty path segment, got "${payload.name}"`, - details: { path: payload.name }, - }) - } - path = join(defaults.workspaceRoot, name) - } else { - path = payload.path as string - } + const { path } = request.payload try { - const name = payload.name?.trim() - const { workspace, created } = await ensureWorkspace( - path, - name, - name !== undefined, - name !== undefined, - ) + const { workspace, created } = await ensureWorkspace(path) return ok(request, { workspace: workspaceView(workspace), created }) } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - if (error instanceof WorkspaceDirectoryCreationError) { - return err(request, { code: 'internal', message: error.message, details: {} }) - } // The registry rejects a path that does not resolve to an existing // directory (realpath ENOENT / not-a-directory) — the business // error of the typed-path flow, surfaced as a validation failure. diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 20b3038301..5ad5a0b96b 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({ archivedSessionIds: z.array(sessionIdSchema), }) satisfies z.ZodType>> -/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ +/** workspace.create request payload: the existing directory to adopt. */ export const workspaceCreateRequestSchema = z.object({ - path: z.string().optional(), - name: z.string().optional(), -}).refine( - payload => (payload.path === undefined) !== (payload.name === undefined), - { message: 'workspace.create requires exactly one of path / name' }, -) satisfies z.ZodType>> + path: z.string(), +}) satisfies z.ZodType>> /** workspace.create response value. */ export const workspaceCreateValueSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index d5307e27a8..64feb27f80 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -46,19 +46,14 @@ export interface WorkspaceApi { list(request: RpcRequest<{}>): Promise> /** - * Creates (or idempotently resolves) a workspace. Exactly one of `path` / - * `name` (schema-enforced): `path` registers an EXISTING directory (no - * mkdir — a missing or non-directory path fails with `workspace-invalid-path`); - * `name` is a single path segment the host mkdirs under its default project - * root before registering. Either spelling resolving to a directory already - * owned by a workspace returns that workspace (`created: false`) for the - * existing-folder spelling. Create-by-name rejects an existing title with - * `workspace-name-conflict`; path adoption allows distinct canonical paths - * whose basenames produce the same display title. - * A new name-created workspace uses `name` as both directory name and title; - * a path-created workspace uses the registry's basename title default. + * Creates (or idempotently resolves) a workspace over an EXISTING directory + * (no mkdir — a missing or non-directory path fails with + * `workspace-invalid-path`). A path resolving to a directory already owned + * by a workspace returns that workspace (`created: false`). Adoption allows + * distinct canonical paths whose basenames produce the same display title; + * the registry's basename title default names the new workspace. */ - create(request: RpcRequest<{ path?: string; name?: string }>): + create(request: RpcRequest<{ path: string }>): Promise> /** diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..a649dabccb 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,6 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. */ -import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import type { ApiProxy } from './api/index.ts' @@ -29,20 +28,18 @@ declare module 'cordis' { } } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ +/** Gateway plugin config: host-level agent routing. */ export interface Config { /** Default provider route for created/resumed agents. */ provider: string /** Default model id. */ model: string - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string } /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory and the fallback parent for name-created Workspaces. + * project directory. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ @@ -53,7 +50,6 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - workspaceRoot: z.string(), }) readonly sessions: ApiProxy['sessions'] @@ -71,12 +67,10 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') - const cwd = process.cwd() const api = createApiProxy(ctx, { provider: config.provider, model: config.model, - cwd, - workspaceRoot: resolve(config.workspaceRoot ?? cwd), + cwd: process.cwd(), }) this.sessions = api.sessions this.subagents = api.subagents diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..4734d4e457 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..008d35d568 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..14f7905633 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -148,7 +148,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -216,7 +216,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -275,7 +275,7 @@ describe('subagent ownership fence', () => { // instead of answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -316,7 +316,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -362,7 +362,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..8b4866eba9 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..e6c799a236 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..4ac934e365 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -85,7 +85,6 @@ const api = (ctx: Context) => createApiProxy(ctx, { provider: 'default-provider', model: 'default-model', cwd: '/tmp', - workspaceRoot: '/tmp', }) describe('sessions.fork', () => { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..87ea408bab 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -125,7 +125,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +160,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c1efc32c97 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..c3ae82fe06 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..b3630eafb5 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..1160d4dd18 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { provider: 'p', model: 'm', cwd: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..580dfa280c 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -88,7 +88,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', + provider: 'p', model: 'm', cwd: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..0427443841 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..b548b36702 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { @@ -102,11 +102,17 @@ async function harness( const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', - cwd: workspaceRoot, - workspaceRoot, + cwd: root, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) - return { api, ctx, storageDomain, workspaceRoot } + return { api, ctx, storageDomain, root } +} + +/** Stage one directory under the harness root for path adoption. */ +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path) + return path } describe('host.pickDirectory', () => { @@ -244,30 +250,26 @@ describe('host.openPath', () => { }) describe('workspace.create', () => { - it('serializes concurrent names and rejects the duplicate', async () => { - const { api, workspaceRoot } = await harness() + it('serializes concurrent creates of one path into a single registration', async () => { + const { api, root } = await harness() + const target = join(root, 'alpha') + mkdirSync(target) const responses = await Promise.all([ - api.workspace.create(request({ name: 'alpha' })), - api.workspace.create(request({ name: 'alpha' })), + api.workspace.create(request({ path: target })), + api.workspace.create(request({ path: target })), ]) - const created = responses.find(response => response.result.ok) - const duplicate = responses.find(response => !response.result.ok) + const values = responses.map(response => expectOk(response)) + const created = values.find(value => value.created) + const resolved = values.find(value => !value.created) - expect(created).toBeDefined() - expect(expectOk(created!)).toMatchObject({ - created: true, - workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' }, - }) - expect(duplicate?.result).toMatchObject({ - ok: false, - error: { code: 'workspace-name-conflict', details: { name: 'alpha' } }, - }) - expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true) + expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) }) - it('adopts only existing directories and rejects unsafe names', async () => { - const { api, workspaceRoot } = await harness() - const existing = join(workspaceRoot, 'existing') + it('adopts only existing directories', async () => { + const { api, root } = await harness() + const existing = join(root, 'existing') mkdirSync(existing) const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) @@ -281,21 +283,16 @@ describe('workspace.create', () => { const reopened = expectOk(await api.workspace.create(request({ path: existing }))) expect(reopened.workspace.title).toBe('renamed-existing') - const missing = join(workspaceRoot, 'missing') + const missing = join(root, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(existsSync(missing)).toBe(false) - - for (const name of ['', '.', '..', 'a/b', 'a\\b']) { - const invalid = await api.workspace.create(request({ name })) - expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) - } }) it('adopts different paths that derive the same Workspace title', async () => { - const { api, workspaceRoot } = await harness() - const first = join(workspaceRoot, 'one', 'project') - const second = join(workspaceRoot, 'two', 'project') + const { api, root } = await harness() + const first = join(root, 'one', 'project') + const second = join(root, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) const firstResult = expectOk(await api.workspace.create(request({ path: first }))) @@ -316,8 +313,8 @@ describe('workspace.create', () => { describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const sessionId = SessionId('session-workspace-preallocated') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -343,8 +340,8 @@ describe('session creation and Workspace membership', () => { }) it('retains a published session when attachment fails and repairs it on retry', async () => { - const { api, ctx } = await harness() - const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const workspace = ctx.workspace.list()[0] if (workspace === undefined) throw new Error('workspace missing from registry') vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) @@ -394,7 +391,7 @@ describe('Host Workspace increments', () => { }) it('streams committed Workspace and Session increments after empty baselines', async () => { - const { api } = await harness() + const { api, root } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) @@ -402,7 +399,7 @@ describe('Host Workspace increments', () => { const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const workspaceIncrement = nextHostFrame(stream) - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace expect(await workspaceIncrement).toMatchObject({ payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, }) @@ -430,7 +427,7 @@ describe('Host Workspace increments', () => { }) it('does not publish a Workspace whose registry-order commit fails', async () => { - const { api, storageDomain } = await harness() + const { api, storageDomain, root } = await harness() const domain = storageDomain.get('workspace') if (domain === undefined) throw new Error('workspace domain is not open') vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) @@ -439,7 +436,7 @@ describe('Host Workspace increments', () => { api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const next = stream.next() - const failed = await api.workspace.create(request({ name: 'ghost' })) + const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) expect(failed.result.ok).toBe(false) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) abort.abort() @@ -447,8 +444,8 @@ describe('Host Workspace increments', () => { }) it('deletes the registration, keeps its session and folder, and streams one removal', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace const sessionId = SessionId('session-kept-after-workspace-delete') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -480,8 +477,8 @@ describe('Host Workspace increments', () => { }) it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { - const { api } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const { api, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace const sessionId = SessionId('session-to-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..a11e168f56 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -378,8 +378,8 @@ describe('workspace domain round trip', () => { expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) - it('rejects a create payload violating the exactly-one refine at the handler', async () => { - const response = await client(scriptedApi()).workspace.create({}) + it('rejects a pathless create payload at the handler schema', async () => { + const response = await client(scriptedApi()).workspace.create({} as never) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..4639b5c5ee 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -324,11 +324,9 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) - it('create requires exactly one of path/name (both refine arms)', () => { + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/) - expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/) + expect(() => workspaceCreateRequestSchema.parse({})).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..5bf96a22a1 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) return { ctx, session, From 40ee7f5e27983e313271fa607d138460ab89b6a7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:48:26 +0800 Subject: [PATCH 10/81] docs,test: settle review follow-ups for the create-by-name deletion Rewrite the three sibling Agent Note pairs that still described create-by-name as current (workspace-ui-product-flow, session-list-browsing-and-manual-order, same-basename-workspace-adoption) and the one-route note's own alternative and section title; delete scripts/hero-composer-dom-continuity.mjs, which drove the name dialog removed by the one-route change; mark WorkspaceRegistry.create's now test-only title parameter with a deletion TODO; pin the retired { name } spelling as a schema rejection; align the workspace spec on stageDir and the fixture spec title on path creates. --- ...same-basename-workspace-adoption.i18n.yaml | 4 +- ...-07-31-same-basename-workspace-adoption.md | 4 +- ...-31-same-basename-workspace-adoption.zh.md | 4 +- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 2 +- ...ssion-list-browsing-and-manual-order.zh.md | 2 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 7 +- ...2026-07-25-workspace-ui-product-flow.zh.md | 7 +- ...-31-one-route-to-add-a-workspace.i18n.yaml | 4 +- ...2026-07-31-one-route-to-add-a-workspace.md | 4 +- ...6-07-31-one-route-to-add-a-workspace.zh.md | 4 +- .../client/connection/tests/fixture.spec.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 6 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + packages/workspace/workspace/src/index.ts | 5 ++ scripts/hero-composer-dom-continuity.mjs | 78 ------------------- 17 files changed, 34 insertions(+), 109 deletions(-) delete mode 100644 scripts/hero-composer-dom-continuity.mjs diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml index 990e7e39bb..e7bdf260be 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md -2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97 -2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3 +2026-07-31-same-basename-workspace-adoption.md: 1192558632fdc8bd732ea59f0eca5051f49f5d2c +2026-07-31-same-basename-workspace-adoption.zh.md: 9c1f1ffd221936e24300b223ad395e1f44552810 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md index ed53804ea6..1192558632 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -14,7 +14,7 @@ A Workspace is identified by its stable id and canonical directory path, while i The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. -Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). +Explicit naming remains stricter. `workspace.rename` continues to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles. @@ -30,7 +30,7 @@ Workspace registry and Host API tests create two real directories under differen **Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful. -**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths. +**Permit collisions from the explicit rename operation too.** The registry supports that state, but rename intentionally asks the user to choose a display name. Retaining its conflict response preserves the existing naming guard without blocking filesystem-selected paths. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md index 82cfb7d90a..9c1f1ffd22 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -14,7 +14,7 @@ Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 -显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 +显式命名仍采用更严格的规则。`workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。 @@ -30,7 +30,7 @@ Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级 **将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。 -**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 +**也允许显式重命名操作产生重名。** 注册表支持这种状态,但该操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index fe955d125d..c8f5bfdce4 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: bd04e7f74c8a4540d68e60ad68965e05de76bce9 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 8ec5f71943a68a70f46fbd4c7702e4556b1892ec +2026-07-25-session-list-browsing-and-manual-order.md: 2894542e7b3b720702c764dbae4112b001e2602c +2026-07-25-session-list-browsing-and-manual-order.zh.md: 81c613a6d2ac738a993f82207ef7c3820cd751f0 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index bd04e7f74c..2894542e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -24,7 +24,7 @@ The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode rend ### workspace.rename -`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with create-by-name, so concurrent explicit naming operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with path adoption and deletion, so concurrent workspace operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. ### Manual order: insertSessionBefore replaces activity pinning diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 8ec5f71943..81c613a6d2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -24,7 +24,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 ### workspace.rename -`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按路径收编和删除共链,并发的 Workspace 操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 ### 手动排序:insertSessionBefore 取代活动置顶 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index d8232afa44..c12ab62d53 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 7a6a41e19d2930fbcbf7ba5fc9e6809d96e23166 -2026-07-25-workspace-ui-product-flow.zh.md: a40f374fd794b11bff0de72cbc822fd638cd267c +2026-07-25-workspace-ui-product-flow.md: 9f241562c2d07801b22619c8e1406984bab22aba +2026-07-25-workspace-ui-product-flow.zh.md: 7fca17d32837deede4fb751ca4317582d796e005 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 7a6a41e19d..9f241562c2 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -19,13 +19,12 @@ The Host provides the following GUI wiring on the Workspace entity: | RPC | Behavior | | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). +The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -52,7 +51,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); explicit create-by-name and rename operations retain their duplicate-title checks. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -108,7 +107,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. -- The UI and Host admit distinct same-basename directories as separate Workspaces, while explicit create-by-name and rename operations reject duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index a40f374fd7..7fca17d328 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -19,13 +19,12 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | RPC | 行为 | | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | -| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 +Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。 @@ -52,7 +51,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的按名称创建和重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -108,7 +107,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 -- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的按名称创建和重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 691511d76b..e1a166fd0c 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192 -2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794 +2026-07-31-one-route-to-add-a-workspace.md: d0a1a820a8eb0a47245d99635b5e1e0448d7eca5 +2026-07-31-one-route-to-add-a-workspace.zh.md: 3486fcdae24a77d3ba82234355ba52d5f43e22d0 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 853d641e0a..d0a1a820a8 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -25,7 +25,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i `WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`. -## Wire and CLI residue +## Wire and CLI follow-up (shipped) Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. @@ -45,7 +45,7 @@ Deleted in the follow-up change this section used to scope: `workspace.create` a **Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now. -**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten. +**Delete the wire's create-by-name branch in the same change.** Rejected for the UI PR: it was backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision was the UI. The deletion shipped as its own follow-up change; the follow-up section above records what it removed. **Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 6a2884d751..3486fcdae2 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -25,7 +25,7 @@ Status: implemented `WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。 -## Wire and CLI residue +## Wire and CLI follow-up (shipped) 本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。 @@ -45,7 +45,7 @@ Status: implemented **为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。 -**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。 +**在同一改动中删除 wire 的按名称创建分支。** UI PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而当时紧急的决定是 UI。删除随后作为独立的后续改动落地,上文 follow-up 一节记录了它移除的内容。 **在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。 diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 5b9824fcde..6bb6e2e4ab 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -535,7 +535,7 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { const api = createFixtureApi() const abort = new AbortController() const seen: HostFrame[] = [] diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index b548b36702..15b2e47989 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -252,8 +252,7 @@ describe('host.openPath', () => { describe('workspace.create', () => { it('serializes concurrent creates of one path into a single registration', async () => { const { api, root } = await harness() - const target = join(root, 'alpha') - mkdirSync(target) + const target = stageDir(root, 'alpha') const responses = await Promise.all([ api.workspace.create(request({ path: target })), api.workspace.create(request({ path: target })), @@ -269,8 +268,7 @@ describe('workspace.create', () => { it('adopts only existing directories', async () => { const { api, root } = await harness() - const existing = join(root, 'existing') - mkdirSync(existing) + const existing = stageDir(root, 'existing') const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 4639b5c5ee..90cee6e8cd 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -327,6 +327,8 @@ describe('workspace domain schemas', () => { it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(() => workspaceCreateRequestSchema.parse({})).toThrow() + // The retired create-by-name spelling stays a clean schema rejection. + expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 71401862f2..904959c8ec 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -139,6 +139,11 @@ export class WorkspaceRegistry extends Service { * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ + // TODO: `title` lost its last production caller when the gateway's + // create-by-name branch was deleted + // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md); + // drop the parameter with its @param clause and the `create(path, title?)` + // lines in this package's README pair. async create(path: string, title?: string): Promise { const canonical = await realpathNormalize(path) if (!(await stat(canonical)).isDirectory()) { diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs deleted file mode 100644 index ef39052b1b..0000000000 --- a/scripts/hero-composer-dom-continuity.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// Regression drive for the unified hero composer (0729-0357-hero-unify): -// cold start with zero workspaces -> create a workspace -> type. Asserts the -// composer textarea is the SAME DOM node across the disabled->live flip (a -// remount drops the __heroMark marker property) — the session-maybe -// composer.bar contract. -// -// Prereqs: `pnpm run build`, then a fresh server against empty state: -// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \ -// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \ -// --workspace-root $(mktemp -d) -// Run: node scripts/hero-composer-dom-continuity.mjs -// (BASE_URL overrides the target; screenshots land in .artifacts/.) -import { createRequire } from 'node:module' - -// playwright is a devDependency of apps/web only — resolve through its tree. -const require = createRequire(new URL('../apps/web/package.json', import.meta.url)) -const { chromium } = require('playwright') - -const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285' -const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) -page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) }) -page.on('pageerror', err => { console.log('[pageerror]', err.message) }) - -await page.goto(BASE) -await page.waitForSelector('textarea', { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '01-cold-start.png' }) - -const initial = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i }) - return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder })) -}) -console.log('cold-start textareas:', JSON.stringify(initial)) - -// Open the picker and create a workspace by name (typed-input flow). The name -// must be unique per registry; keystrokes go through pressSequentially so the -// dialog's React onChange enables the submit button. -await page.getByRole('button', { name: 'Choose workspace' }).click() -await page.getByText('Create a new workspace').click() -await page.screenshot({ path: SHOTS + '03-create-form.png' }) -const nameBox = page.getByPlaceholder('Workspace name') -await nameBox.click() -const wsName = 'proj-' + Date.now().toString(36) -await nameBox.pressSequentially(wsName, { delay: 30 }) -await page.locator('button:text-is("Create workspace")').click() - -// Wait for the composer to go live (placeholder flips, textarea enabled). -await page.waitForFunction(() => { - const box = document.querySelector('textarea') - return box !== null && !box.disabled -}, { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '04-live.png' }) - -const after = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - return boxes.map(b => ({ - mark: b.__heroMark ?? 'REMOUNTED', - disabled: b.disabled, - placeholder: b.placeholder, - })) -}) -console.log('post-pick textareas:', JSON.stringify(after)) - -// Type into the live composer. -await page.locator('textarea').first().fill('hello from acceptance run') -const typed = await page.evaluate(() => document.querySelector('textarea')?.value) -console.log('typed value:', JSON.stringify(typed)) -await page.screenshot({ path: SHOTS + '05-typed.png' }) - -const survived = after.length === 1 && after[0].mark === 'alive-0' -console.log(survived - ? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)' - : 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after)) -await browser.close() -process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1) From b7fe3de8f184cdcec5ab20eb7205660368e4038a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 7 Aug 2026 14:54:07 +0800 Subject: [PATCH 11/81] chore(knip): drop the stale playwright ignore for the deleted hero script The root-scripts workspace ignore existed only for scripts/hero-composer-dom-continuity.mjs; apps/web declares its own playwright dependency for the e2e lane. --- knip.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/knip.json b/knip.json index 6dc4b56dcd..fbf456d3b9 100644 --- a/knip.json +++ b/knip.json @@ -27,9 +27,6 @@ "scripts/**/*.ts", "scripts/**/*.mjs", "scripts/**/*.cjs" - ], - "ignoreDependencies": [ - "playwright" ] }, "examples": { From 94f61d29fa7da8021735688aafb04cb528648133 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 19:21:43 +0800 Subject: [PATCH 12/81] fix(scripts): register dsh-base windows.cordis.patch.yml in workspace constraints --- scripts/check-workspace-constraints.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..f0cea80064 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -102,8 +102,9 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib. - '@deepseek-ai/dsh-base': ['cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib; + // dsh-base also ships the win32 shell platform layer the launcher reads. + '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], From 1ee773317cccb88ddd35aec6ab49678ae69552c4 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 19:21:49 +0800 Subject: [PATCH 13/81] docs(bundle): complete the Windows pwsh default contract per review - apps/cli/reference/README: state the win32 permission/sandbox/approval degradation so the workspace-write promise no longer misleads Windows users - bundle README + agent note: give the complete bash-restore recipe (disable pwsh-local/tool-pwsh and re-enable bash-sandbox/tool-bash), since both executors register the same bash service and an incomplete recipe fails loud at load - windows.cordis.patch.yml: header comment notes the recipe and that the ui-permission row belongs to dsh-web-app (base-only profiles get a harmless no-match warning) - profile-boot.ts: rewrap composeProfile JSDoc - re-record i18n hashes for the touched bilingual pairs --- .../feature/2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../feature/2026-08-01-windows-pwsh-default.md | 2 +- .../feature/2026-08-01-windows-pwsh-default.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/profile-boot.ts | 10 +++++----- packages/bundle/base/README.i18n.yaml | 4 ++-- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/windows.cordis.patch.yml | 8 ++++++++ 11 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 7ab92d8199..48f7c38aa5 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 75a27b5140abacf77b5f20028752a4903a50eaec -2026-08-01-windows-pwsh-default.zh.md: 8b940f1973835f8143bde05b37eba6f00e27f8f4 +2026-08-01-windows-pwsh-default.md: 4cd17497c0185ba61fb4994a9167d114640b4866 +2026-08-01-windows-pwsh-default.zh.md: 201e5dd2c7c0b791bf6474261846bc7f4061cc5b diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 75a27b5140..4cd17497c0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -35,7 +35,7 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). - Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — composition config is the one override channel. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 8b940f1973..201e5dd2c7 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -35,7 +35,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 - Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——组合配置是唯一的覆盖通道。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 07d7810529..29cdfac304 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 -README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf +README.md: 07b162517b90215a6552810124f15c435144f7d4 +README.zh.md: 80030707474e59e76583ed7edc5ddce56334cc03 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8b8a0e7dbe..07b162517b 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -51,7 +51,7 @@ Process shutdown gives the plugin tree up to five seconds to dispose. The first All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup. -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. +New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. On win32 hosts booting a shipped profile, the Windows platform layer removes the permission, sandbox, and approval rows entirely: the pwsh shell and the fs tools run unconfined with no workspace boundary (Windows has no OS sandbox runner — landlock/bwrap/seatbelt are POSIX-only — so the shipped posture is honest danger-full-access rather than a boundary the shell could bypass), and `DSH_PERMISSION_MODE` and stored permission settings have no effect there. `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d1d6d5a594..8003070747 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -51,7 +51,7 @@ dsh web --dump-config 所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。 -新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 +新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。在 win32 主机启动交付 profile 时,Windows 平台层会整体移除 permission、sandbox 与 approval 行:pwsh shell 与 fs 工具不受限运行,不存在 workspace 边界(Windows 上没有 OS 级 sandbox runner——landlock/bwrap/seatbelt 均为 POSIX 专属——因此交付姿态是诚实的 danger-full-access,而不是一个 shell 可以绕过的边界),`DSH_PERMISSION_MODE` 与存储的权限设置在彼处也不生效。 `DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 7115f1208a..6dab902845 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -132,11 +132,11 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { /** * Load `name` and compose its effective patch stack: bundle layers in * `dsh.profile.bundles` order, the win32 shell platform layer (when the host - * is Windows), the - * profile's user layer, the home-level user layer (`$DSH_HOME/cordis.patch.yml` - * — machine-local preferences that apply to every profile, so it outranks the - * per-profile layer), `--patch` overlays, then flag patches derived from the - * composed rows, then the telemetry switch. + * is Windows), the profile's user layer, the home-level user layer + * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to + * every profile, so it outranks the per-profile layer), `--patch` overlays, + * then flag patches derived from the composed rows, then the telemetry + * switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 687c86ae0b..cc58dd3f8f 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 84e7f43aaa6cfead0ff28e30063e71062d358f0a -README.zh.md: 30c7c259f8893ebd34da6cab362dedac067ce099 +README.md: 9e2a6305fff592eb63f927dfcc4f32437edeb1cc +README.zh.md: 6159c23047bba4ced3345125901cc0c3ff7cd960 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 84e7f43aaa..9e2a6305ff 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack overrides these rows through its profile or home `cordis.patch.yml`. POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack restores it through its profile or home `cordis.patch.yml` (disable `pwsh-local`/`tool-pwsh` and re-enable `bash-sandbox`/`tool-bash` — both executors register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 30c7c259f8..6159c23047 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 恢复 bash 栈(禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 439861f922..1abd4ba568 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -13,6 +13,14 @@ # dsh.bundle.patch — that field names the one universal layer). A Windows # host that prefers bash or confinement overrides these rows through its # profile or home cordis.patch.yml. +# The bash-restore recipe must be complete: disable pwsh-local and tool-pwsh +# AND re-enable bash-sandbox and tool-bash (plus permission/ui-permission only +# if the switcher is wanted) — both executors register the same 'bash' +# service, so re-enabling the bash rows while pwsh-local stays inserted fails +# loud at load on a duplicate registration. +# The ui-permission disable targets a row owned by dsh-web-app, not dsh-base: +# a base-only profile (e.g. the `dsh plugin --profile` default template) has +# no such row, and the no-match logs a harmless warning on every load. - id: bash-sandbox disabled: true From 431783426e62fa5897a503546ba39f705260e173 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 7 Aug 2026 20:50:36 +0800 Subject: [PATCH 14/81] test(cli): compose the real Windows shell roster through the shipped bundle layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver suite previously exercised only fixture patch lists, leaving the real shipped windows.cordis.patch.yml and the bundle→windows→user composition ordering untested on Linux CI. The new cases load a temp profile whose bundle layers resolve from the real dsh-base/dsh-web-app packages (app installation anchor), apply the platform layer through the boot's own composeEntries algorithm with the platform injected, and assert the win32 danger-full-access roster (eight disables, three inserts, no warnings on the web profile). A second case pins POSIX unchanged and the base-only-profile ui-permission no-match warning as warned-but-harmless, matching the patch header's documented contract. --- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 2 +- .../2026-08-01-windows-pwsh-default.zh.md | 2 +- apps/cli/tests/windows-shell.spec.ts | 60 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 48f7c38aa5..af9f64208f 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: 4cd17497c0185ba61fb4994a9167d114640b4866 -2026-08-01-windows-pwsh-default.zh.md: 201e5dd2c7c0b791bf6474261846bc7f4061cc5b +2026-08-01-windows-pwsh-default.md: ea45040a0eb6f1325ed8f3a02a477d5dfd614696 +2026-08-01-windows-pwsh-default.zh.md: 41324afccdd775cac1629ed69217cff4237be091 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index 4cd17497c0..ea45040a0e 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -39,6 +39,6 @@ The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier w ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure, with the platform injected; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows roster (disables, inserts, and the absent approval service). +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 201e5dd2c7..41324afccd 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -39,6 +39,6 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过与缺文件失败,平台注入;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows 清单(禁用、插入与缺席的 approval 服务)。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index f0cfddbaae..80ba40cc34 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' +import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' import { BASE_BUNDLE, resolveWindowsShellLayer, @@ -62,3 +64,61 @@ describe('resolveWindowsShellLayer', () => { .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) }) }) + +describe('the shipped Windows composition (real bundle layers)', () => { + let home: string + afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) }) + // The app installation anchor, mirroring profile-boot.ts: the bundle layers + // resolve from the REAL dsh-base/dsh-web-app packages through it, so this + // suite composes the shipped patch files, not test fixtures. + const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) + + it('composes the win32 danger-full-access roster through the real patch layers', () => { + home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) + initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const profile = loadProfile('dsh', 'web', anchor, home) + const warnings: string[] = [] + const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh') + expect(win32).toBeDefined() + const rows = composeEntries( + [...profile.layers.map(layer => layer.patches), win32!.patches], + message => warnings.push(message), + ) + const byId = new Map(rows.map(row => [row.id, row])) + for (const id of ['bash-sandbox', 'tool-bash', 'permission', 'ui-permission', + 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) + } + for (const id of ['pwsh-local', 'tool-pwsh', 'fs-local']) { + expect(byId.has(id), `inserted row ${id}`).toBe(true) + } + // The web-app layer provides ui-permission, so the full web profile + // composes without any no-match warning. + expect(warnings).toEqual([]) + }) + + it('leaves POSIX untouched and base-only profiles warned but harmless', () => { + home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) + initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const profile = loadProfile('dsh', 'web', anchor, home) + // POSIX: no platform layer, the bash stack stays enabled. + const posixRows = composeEntries(profile.layers.map(layer => layer.patches)) + const posixById = new Map(posixRows.map(row => [row.id, row])) + expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) + expect(posixById.has('pwsh-local')).toBe(false) + + // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): + // ui-permission has no row to patch, so the shipped layer warns once per + // composition — never fails — exactly as its header comment documents. + initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) + const baseOnly = loadProfile('dsh', 'base-only', anchor, home) + const baseWarnings: string[] = [] + const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh') + expect(win32).toBeDefined() + composeEntries( + [...baseOnly.layers.map(layer => layer.patches), win32!.patches], + message => baseWarnings.push(message), + ) + expect(baseWarnings.some(message => message.includes('ui-permission'))).toBe(true) + }) +}) From f64ba40f43961fc65b7e1724132708a0b6d163cf Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 01:24:33 +0800 Subject: [PATCH 15/81] feat(sandbox): Windows ACL write-restriction sandbox (restricted-token runner) Confine Windows command execution through a WRITE_RESTRICTED token whose restricting SIDs carry an orphan-SID write allowlist, ported from https://github.com/huoyaoyuan/windows-acl-restrict-poc (@ 10e4dfb). Every Win32 call is checked and fails closed - the POC silently ran children with the FULL token when CreateRestrictedToken failed. - @deepseek-ai/dsh-sandbox-windows-acl: koffi primitives verified against the MinGW Windows headers (verify/abi-probe.cpp) plus the confinement runner ([node, runner, --workspace, --temp, --mode, --, argv...]: kill-on-close job, stdio passthrough, exit-code mirroring, windows-acl-run: failure signature, grant revocation). read-only = strict zero grants (NUL device not writable; documented). Windows-only execution: exempted from the Linux coverage lane (windowsOnlyCoverageExclusions). - @deepseek-ai/dsh-sandbox-local: PLATFORM_CHAINS.win32 filled with the windows-acl runner (full enforcement, ACL denial dialect, runner-failure rules). - @deepseek-ai/dsh-pwsh-sandbox: sandbox-consuming pwsh executor (call-for-call mirror of dsh-bash-sandbox) over a new argv-level seam in dsh-pwsh-local; per-file coverage complete via the fake-provider spec. - bundle/base: the Windows platform layer mounts the confined pwsh roster - sandbox/policy/fs-sandbox/permission/approval re-enabled, the POSIX bash stack stays disabled. Co-authored-by: Huo Yaoyuan --- packages/bash/pwsh-local/src/index.ts | 48 ++- packages/bash/pwsh-sandbox/README.i18n.yaml | 6 + packages/bash/pwsh-sandbox/README.md | 24 ++ packages/bash/pwsh-sandbox/README.zh.md | 24 ++ packages/bash/pwsh-sandbox/package.json | 46 +++ packages/bash/pwsh-sandbox/src/helpers.ts | 120 +++++++ packages/bash/pwsh-sandbox/src/index.ts | 184 ++++++++++ packages/bash/pwsh-sandbox/src/invariant.ts | 30 ++ packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 115 +++++++ .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 320 ++++++++++++++++++ packages/bash/pwsh-sandbox/tsconfig.json | 39 +++ packages/bundle/base/tests/base.spec.ts | 36 +- packages/bundle/base/windows.cordis.patch.yml | 59 +--- packages/sandbox/sandbox-local/package.json | 3 +- packages/sandbox/sandbox-local/src/index.ts | 85 ++++- .../sandbox/sandbox-local/tests/local.spec.ts | 11 +- .../sandbox-windows-acl/README.i18n.yaml | 6 + .../sandbox/sandbox-windows-acl/README.md | 64 ++++ .../sandbox/sandbox-windows-acl/README.zh.md | 64 ++++ .../sandbox/sandbox-windows-acl/package.json | 44 +++ .../sandbox/sandbox-windows-acl/src/acl.ts | 113 +++++++ .../sandbox/sandbox-windows-acl/src/errors.ts | 21 ++ .../sandbox/sandbox-windows-acl/src/ffi.ts | 307 +++++++++++++++++ .../sandbox/sandbox-windows-acl/src/index.ts | 280 +++++++++++++++ .../sandbox-windows-acl/src/invariant.ts | 31 ++ .../sandbox/sandbox-windows-acl/src/runner.ts | 135 ++++++++ .../sandbox/sandbox-windows-acl/src/spawn.ts | 296 ++++++++++++++++ .../sandbox/sandbox-windows-acl/src/token.ts | 138 ++++++++ .../sandbox-windows-acl/src/win32-abi.ts | 136 ++++++++ .../sandbox-windows-acl/tests/probe.spec.ts | 97 ++++++ .../tests/provider-chain.spec.ts | 58 ++++ .../sandbox-windows-acl/tests/runner.spec.ts | 107 ++++++ .../sandbox/sandbox-windows-acl/tsconfig.json | 22 ++ .../sandbox-windows-acl/tsdown.config.ts | 16 + .../sandbox-windows-acl/verify/abi-probe.cpp | 177 ++++++++++ pnpm-lock.yaml | 49 +++ tsconfig.host.json | 2 + vitest.config.ts | 11 + 38 files changed, 3230 insertions(+), 94 deletions(-) create mode 100644 packages/bash/pwsh-sandbox/README.i18n.yaml create mode 100644 packages/bash/pwsh-sandbox/README.md create mode 100644 packages/bash/pwsh-sandbox/README.zh.md create mode 100644 packages/bash/pwsh-sandbox/package.json create mode 100644 packages/bash/pwsh-sandbox/src/helpers.ts create mode 100644 packages/bash/pwsh-sandbox/src/index.ts create mode 100644 packages/bash/pwsh-sandbox/src/invariant.ts create mode 100644 packages/bash/pwsh-sandbox/tests/acl.e2e.ts create mode 100644 packages/bash/pwsh-sandbox/tests/sandbox.spec.ts create mode 100644 packages/bash/pwsh-sandbox/tsconfig.json create mode 100644 packages/sandbox/sandbox-windows-acl/README.i18n.yaml create mode 100644 packages/sandbox/sandbox-windows-acl/README.md create mode 100644 packages/sandbox/sandbox-windows-acl/README.zh.md create mode 100644 packages/sandbox/sandbox-windows-acl/package.json create mode 100644 packages/sandbox/sandbox-windows-acl/src/acl.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/errors.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/ffi.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/index.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/invariant.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/runner.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/spawn.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/token.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/win32-abi.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tsconfig.json create mode 100644 packages/sandbox/sandbox-windows-acl/tsdown.config.ts create mode 100644 packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 20ecffd969..ceef4725fb 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + /** + * The pwsh invocation argv for one resolved spec — the argv-level seam a + * confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of + * `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see + * `@deepseek-ai/dsh-pwsh-sandbox`). + */ + protected argv(spec: BashExecSpec): string[] { + return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`] + } + + /** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */ + private spawnSpec( + spec: BashExecSpec, + stdoutMaxBytes: number, + signal: AbortSignal | undefined, + argv: readonly string[], + ): SubprocessSpawnSpec { const collect = (maxBytes: number): SubprocessCollect => ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { - argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`], + argv: [...argv], cwd: spec.workdir, stdio: { stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', @@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor { } async run(spec: BashExecSpec): Promise { + return this.runArgv(spec, this.argv(spec)) + } + + /** Foreground run of an exact argv (the confining subclass re-wraps it). */ + protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv)) const outcome = await handle.done const collected = PwshLocalExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. @@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor { } start(spec: BashExecSpec): BashProcess { + return this.startArgv(spec, this.argv(spec)) + } + + /** Background start of an exact argv (the confining subclass re-wraps it). */ + protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv)) const collected = PwshLocalExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing @@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, collected.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text, false) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' spawnFailureNote = `spawn failed: ${String(error)}` - this.onProcessDone(proc, spawnFailureNote) + this.onProcessDone(proc, spawnFailureNote, true, error) }), readOutput: (): BashProcessRead => { const out = collected.stdout.readFrom(stdoutOffset) @@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor { /** * Settlement hook for subclasses that attach execution facts to a process. * The base implementation is intentionally empty. Mirrored from - * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is - * the declared seam for a future pwsh-confining subclass and has no consumer - * in this package yet. + * `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the + * pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`. * @param _proc - the settled process handle. * @param _stderr - the process's retained stderr tail used by subclasses for settlement classification. + * @param _spawnFailed - whether the spawn rejected before any process existed. + * @param _spawnError - the spawn rejection, when `_spawnFailed`. */ - protected onProcessDone(_proc: BashProcess, _stderr: string): void {} + protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {} } /* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml new file mode 100644 index 0000000000..7110763e72 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md +README.md: 5eaa513b7802fe1b4411a5c0bafabafd232d4da7 +README.zh.md: 4924feed26cb7bc50d4863a063d9a4b0bf26d954 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md new file mode 100644 index 0000000000..5eaa513b78 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +English | [中文](README.zh.md) + +Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command ` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt. + +The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy). + +## Behavior + +- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`. +- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`. + +## Model Experience + +### Confinement works, denial surfaces as command failure + +The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +## Known Limitations and Deferred Work + +- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`), mirroring Landlock's `/tmp` grant — a per-run private temp would need an env-block rewrite in the runner and is deferred. +- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md new file mode 100644 index 0000000000..4924feed26 --- /dev/null +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-pwsh-sandbox + +[English](README.md) | 中文 + +沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 + +执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 + +## 行为 + +- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。 +- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。 + +## 模型体验 + +### 隔离生效,拒绝以命令失败呈现 + +模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +## 已知限制与后续工作 + +- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`),与 Landlock 授予 `/tmp` 同语义——按运行创建私有临时目录需要 runner 改写环境块,留待后续。 +- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json new file mode 100644 index 0000000000..47f51b2ac1 --- /dev/null +++ b/packages/bash/pwsh-sandbox/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-pwsh-sandbox", + "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pwsh-local": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/bash/pwsh-sandbox/src/helpers.ts b/packages/bash/pwsh-sandbox/src/helpers.ts new file mode 100644 index 0000000000..44524a3d87 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/helpers.ts @@ -0,0 +1,120 @@ +/** + * Internal sandbox-result classification helpers — deliberate call-for-call + * mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of + * the bash consumer shares the identical classification dialect). + * + * @module @deepseek-ai/dsh-pwsh-sandbox/helpers + */ + +/* jscpd:ignore-start */ +import { accessSync, constants, statSync } from 'node:fs' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox' + +/** Node-local spawn codes proven to identify executable resolution or permission failure. */ +const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT']) + +/** Whether the caller-owned spawn cwd can be entered. */ +function isUsableWorkdir(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance + * after independently ruling out the caller-owned cwd. A supplied error path + * must exactly identify the runner; without one, the syscall must. With a + * usable cwd, these codes describe resolution or execute permission for that + * argv[0] or its shebang interpreter. + * The workdir is checked at classification time, not atomically with spawn; + * concurrent path replacement may change attribution but cannot permit an + * unconfined execution. + * @param error - the original spawn rejection. + * @param runnerProgram - provider argv[0], the executable that establishes confinement. + * @param workdir - the caller-owned spawn cwd, checked independently for usability. + * @returns whether the rejection has executable-specific runner evidence. + */ +export function isRunnerSpawnFailure( + error: unknown, + runnerProgram: string | undefined, + workdir: string, +): boolean { + if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false + if (typeof error !== 'object' || error === null) return false + const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown } + if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false + if (typeof syscall !== 'string') return false + const exactSyscall = `spawn ${runnerProgram}` + if (path === undefined) return syscall === exactSyscall + if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false + return syscall === 'spawn' || syscall === exactSyscall +} + +/** Fatal runner evidence retained for infrastructure-error detail. */ +interface RunnerFailureMatch { + /** The original stderr line that matched a fatal signature. */ + detail: string +} + +/** + * Classify a failed run against the selected backend's denial dialect. + * @param result - settled foreground run. + * @param signatures - case-insensitive denial substrings from the active wrap. + * @returns whether the failed run matches that denial dialect. + */ +export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean { + return matchesSignature(result.exitCode, result.stderr.text, signatures) +} + +/** + * Classify one settled process against the selected backend's structured + * runner-failure rules. Each rule requires a nonzero exit, its optional + * exit-code gate, and a fatal signature on one stderr line after exact + * informational lines are excluded. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text, left unchanged. + * @param rules - structured runner-failure rules from the active wrap. + * @returns the first matching fatal line, or undefined when evidence is insufficient. + */ +export function classifyRunnerFailure( + exitCode: number | null, + stderr: string, + rules: readonly RunnerFailureRule[], +): RunnerFailureMatch | undefined { + if (exitCode === null || exitCode === 0) return undefined + const lines = stderr.split(/\r?\n/) + for (const rule of rules) { + if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue + const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase())) + // An empty or whitespace-only substring is not meaningful runner evidence. + // Ignore it while keeping any valid signatures beside it active. + const fatalSignatures = rule.fatalSignatures + .filter(signature => signature.trim().length > 0) + .map(signature => signature.toLowerCase()) + for (const line of lines) { + const lowered = line.toLowerCase() + if (informationalLines.has(lowered)) continue + if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line } + } + } + return undefined +} + +/** + * Match a non-zero exit against case-insensitive stderr signatures. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text. + * @param signatures - substrings identifying the selected backend's dialect. + * @returns whether this is a non-zero exit whose stderr matches a signature. + */ +export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean { + if (exitCode === null || exitCode === 0) return false + const lowered = stderr.toLowerCase() + return signatures.some(signature => lowered.includes(signature.toLowerCase())) +} +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts new file mode 100644 index 0000000000..c88719f889 --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -0,0 +1,184 @@ +/** + * Sandbox-consuming PowerShell executor — the pwsh twin of + * `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through + * `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner + * chain), inherits local process mechanics, and reports the selected mode, + * enforcement, and denial facts. Positive runner-launch evidence means the + * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while + * background processes carry `runnerFailed`; other spawn rejections retain + * local-executor semantics. The tool owns approval and passes a complete + * per-call policy. + * @module @deepseek-ai/dsh-pwsh-sandbox + */ + +import { Context } from 'cordis' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { + ConfinedArgv, + ConfinedSandboxMode, + RunnerFailureRule, + SandboxEnforcement, + SandboxExecutionPolicy, + SandboxMode, + SandboxPolicy, +} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local' +import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts' + +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig + +/** + * Registers as `ctx.bash` in place of the local pwsh executor and requires a + * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is + * unchanged. Tool calls pass the calling session's resolved policy; direct + * calls fall back to deployment policy. `result.sandbox` reports the mode and + * enforcement actually used. + */ +export class SandboxPwshExecutor extends PwshLocalExecutor { + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] + + // No own Config: the sandbox default (mode + workspaceRoot) moved to + // ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config + // verbatim (the config catalog walks the inherited static). + + private readonly mode: SandboxMode + /** + * Per-process confinement facts retained until settlement. Providers may + * vary enforcement and diagnostic dialect between overlapping calls, so a + * shared latest-wrap value would classify a process against the wrong facts. + * Unconfined processes have no entry. + */ + private readonly processFacts = new Map() + + constructor(ctx: Context, config: Config) { + super(ctx, config) + // The default mode is the capability fact used for schema advertisement; + // actual tool executions carry their resolved per-call policy. + this.mode = ctx.sandboxPolicy.defaultMode + } + + /** The configured default mode — the capability fact the tool layer reads. */ + override get sandboxMode(): SandboxMode { + return this.mode + } + + /** + * Stamp a complete per-call policy onto the spec. Tool calls supply the + * calling session's resolved mode and root; lower-level callers fall back to + * the deployment policy. + */ + override resolve(request: BashExecRequest): BashExecSpec { + return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() } + } + + override async run(spec: BashExecSpec): Promise { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') { + const result = await super.run(spec) + return { ...result, sandbox: { mode, denied: false } } + } + const confined = this.confine(spec, { ...policy, mode }) + let result: BashRunResult + try { + result = await this.runArgv(spec, confined.argv) + } catch (error) { + // An upstream abort remains cancellation even when it prevents spawn. + if (spec.signal?.aborted === true) spec.signal.throwIfAborted() + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + // Runner failure outranks denial because the command did not run. Carry + // the matched fatal line, not an informational line that preceded it. + const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules) + if (runnerFailure !== undefined) { + throw new SandboxUnavailableError(mode, runnerFailure.detail) + } + return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } } + } + + override start(spec: BashExecSpec): BashProcess { + const policy = spec.sandboxPolicy as SandboxExecutionPolicy + const { mode } = policy + if (mode === 'danger-full-access') return super.start(spec) + // Once startArgv returns, install facts synchronously; promise settlement + // cannot run before start() returns. + const confined = this.confine(spec, { ...policy, mode }) + let proc: BashProcess + try { + proc = this.startArgv(spec, confined.argv) + } catch (error) { + if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { + throw new SandboxUnavailableError(mode, String(error)) + } + throw error + } + const { enforcement, denialSignatures, runnerFailureRules } = confined + this.processFacts.set(proc, { + mode, + enforcement, + denialSignatures, + runnerFailureRules, + runnerProgram: confined.argv[0], + workdir: spec.workdir, + }) + return proc + } + + /** + * Stamp per-process sandbox facts before `done` settles. Full-access + * processes have no facts; signal deaths are not denials. + */ + protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void { + const facts = this.processFacts.get(proc) + if (facts !== undefined) { + this.processFacts.delete(proc) + // A rejected spawn never started the confined launch. Otherwise runner + // failure outranks denial because its diagnostics may contain denial terms. + const runnerFailed = spawnFailed + ? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir) + : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined + proc.sandbox = { + mode: facts.mode, + denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures), + enforcement: facts.enforcement, + ...(runnerFailed ? { runnerFailed } : {}), + } + } + super.onProcessDone(proc, stderr, spawnFailed, spawnError) + } + + /** + * Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors + * propagate unchanged; the returned argv is handed directly to the local + * executor's subprocess path. + * @param spec - resolved execution spec whose pwsh argv is confined. + * @param policy - resolved confined execution policy. + * @returns the provider's exact argv and settlement-classification facts. + */ + private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv { + return this.ctx.sandbox.confine(this.argv(spec), policy) + } +} + +export default SandboxPwshExecutor diff --git a/packages/bash/pwsh-sandbox/src/invariant.ts b/packages/bash/pwsh-sandbox/src/invariant.ts new file mode 100644 index 0000000000..6afda519ff --- /dev/null +++ b/packages/bash/pwsh-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`. + * @module @deepseek-ai/dsh-pwsh-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'pwsh-sandbox-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond contracts enforced at its owning seams. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts new file mode 100644 index 0000000000..a5909f0544 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -0,0 +1,115 @@ +/** + * Real-backend end-to-end: LocalSandboxProvider (win32 chain → the + * windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with + * REAL pwsh spawns confined through the runner — the debug-instance + * verification of both modes: read-only denies every write (not even NUL), + * workspace-write allows the workspace and temp while denying escape writes, + * and denial/classification facts ride the settled result. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let executor!: SandboxPwshExecutor + + beforeAll(async () => { + // The escape probe must live OUTSIDE every legitimately granted tree: the + // provider's workspace-write grants the workspace plus the REAL temp dir + // (the 'backend-defined temp area', same as Landlock granting /tmp), so a + // scratch dir under temp would inherit the grant and the probe would be a + // false pass. A mkdtemp under the profile is removed by afterAll. + scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir }) + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(SandboxPwshExecutor, {}) + executor = ctx.bash as SandboxPwshExecutor + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => { + const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: DENIED') + expect(result.stdout.text).toContain('TEMP-WRITE: DENIED') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false) + // A self-caught denial keeps the command exit 0: no denial fact. + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + + // A raw failing write must classify as a denial of the ACL dialect. + const denied = await executor.run(executor.resolve({ + command: `Set-Content -Path '${escapeFile}' -Value x`, + sandboxPolicy: policy, + })) + expect(denied.exitCode).not.toBe(0) + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 60_000) + + it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) + expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) + expect(result.stdout.text).toContain('TARGET-WRITE: OK') + expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout.text).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(escapeFile)).toBe(false) + expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + }, 60_000) +}) diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts new file mode 100644 index 0000000000..1c8e5e1bd0 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -0,0 +1,320 @@ +/** + * Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service + * makes wrapping, policy hand-off, fail-closed propagation, and fact stamping + * deterministic; real-provider integration lives in `tests/acl.e2e.ts`. + * Requires pwsh for the integration block (skips without it — same gate as + * pwsh-local's suites); the helpers block is pure and always runs. + */ + +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '../src/index.ts' +import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) + +/** One recorded provider call: the argv handed over and the policy it rode with. */ +interface ConfineCall { + argv: string[] + policy: SandboxPolicy +} + +/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */ +const passthrough = (argv: readonly string[]): ConfinedArgv => + ({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] }) + +/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */ +function throwingSubprocessService(error: unknown): new (ctx: Context) => Service { + return class extends Service { + constructor(ctx: Context) { + super(ctx, 'subprocess') + } + + spawn(): never { + throw error + } + } +} + +async function setup( + behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough, + subprocess: new (ctx: Context) => Service = LocalSubprocessService, +): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> { + const calls: ConfineCall[] = [] + class FakeSandboxProvider extends SandboxProvider { + confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv { + calls.push({ argv: [...argv], policy }) + return behavior(argv, policy) + } + } + const ctx = new Context() + await ctx.plugin(FakeSandboxProvider) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir }) + await ctx.plugin(subprocess) + if (ctx.subprocess instanceof LocalSubprocessService) { + ctx.subprocess.internals = { spillDir } + } + await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 }) + return { executor: ctx.bash as SandboxPwshExecutor, calls } +} + +describe('helpers (pure)', () => { + const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-')) + afterAll(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe('isRunnerSpawnFailure', () => { + const absolute = process.execPath + const bare = 'node' + const relative = './sandbox-runner' + + it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => { + for (const runnerProgram of [absolute, bare, relative]) { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true) + } + }) + + it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => { + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false) + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false) + expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false) + expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false) + // An existing FILE (not a directory) workdir is unusable without throwing. + const fileWorkdir = join(workdir, 'a-file') + writeFileSync(fileWorkdir, 'x') + expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false) + }) + }) + + describe('classifyRunnerFailure', () => { + const rules: readonly RunnerFailureRule[] = [{ + allowedExitCodes: [127], + fatalSignatures: ['fake-runner: '], + informationalLines: ['fake-runner: partial enforcement'], + }] + + it('matches a fatal signature on a gated exit code, skipping informational lines', () => { + expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules)) + .toEqual({ detail: 'fake-runner: profile refused' }) + }) + + it('rejects zero/null exits, gate mismatches, and empty signatures', () => { + expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() + }) + }) + + describe('matchesSignature', () => { + it('matches non-zero exits case-insensitively, never zero or signal exits', () => { + expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true) + expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true) + expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false) + expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false) + expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false) + }) + }) +}) + +describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => { + // Denial device for the POSIX classification cases: a mode-0555 directory + // INSIDE a temp scratch tree (the same device as bash-sandbox's suites) — + // unit tests never attempt writes outside the system temp directory. On + // win32 there is no POSIX mode denial; the real-sandbox denial coverage + // lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths. + const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-')) + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555) + const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')` + + afterAll(() => { + if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755) + rmSync(readOnlyDir, { recursive: true, force: true }) + rmSync(spillDir, { recursive: true, force: true }) + }) + + const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' } + + it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(1) + const call = calls[0] + expect(call?.policy).toEqual(RO) + // The confined argv is the pwsh invocation, ready for a runner prefix. + expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u) + expect(call?.argv).toContain('-NonInteractive') + expect(call?.argv.at(-1)).toContain('echo wrapped') + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => { + const { executor, calls } = await setup() + expect(executor.sandboxMode).toBe('workspace-write') + const result = await executor.run(executor.resolve({ command: 'echo fallback' })) + expect(result.exitCode).toBe(0) + expect(calls[0]?.policy.mode).toBe('workspace-write') + }, 30_000) + + it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => { + const { executor, calls } = await setup() + const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } })) + expect(result.exitCode).toBe(0) + expect(calls).toHaveLength(0) + expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false }) + }, 30_000) + + it('an aborted caller signal outranks runner-spawn attribution', async () => { + const controller = new AbortController() + controller.abort('caller-cancel') + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [], + })) + await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal }))) + .rejects.toThrow('caller-cancel') + }, 30_000) + + // POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the + // real-sandbox denial classification is covered by tests/acl.e2e.ts + // (the ACL runner denies scratch paths — unit tests never leave temp). + it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => { + const { executor } = await setup() + const result = await executor.run(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => { + const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .rejects.toThrow('sync-emfile') + }, 30_000) + + it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => { + const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' }) + const { executor: closed } = await setup(() => ({ + argv: ['node', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + }), throwingSubprocessService(attributable)) + expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow(SandboxUnavailableError) + + const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' }) + const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign)) + expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO }))) + .toThrow('sync-emfile-start') + }, 30_000) + + it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => { + const { executor } = await setup(() => ({ + argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO }))) + .rejects.toThrow(SandboxUnavailableError) + }, 30_000) + + it('background confined runs stamp clean facts at settlement', async () => { + const { executor } = await setup() + const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO })) + await clean.done + expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + }, 30_000) + + // POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial + // coverage lives in tests/acl.e2e.ts. + it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => { + const { executor } = await setup() + const denied = executor.start(executor.resolve({ + command: deniedWriteCommand, + sandboxPolicy: RO, + })) + await denied.done + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + }, 30_000) + + it('background spawn rejections settle as runnerFailed facts', async () => { + const { executor } = await setup(() => ({ + argv: ['definitely-not-a-real-runner', '--', 'pwsh'], + enforcement: 'full', + denialSignatures: [], + runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }], + })) + const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO })) + await proc.done + expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true }) + // The failure note surfaces through the read path. + const read = proc.readOutput() + expect(read.delta).toContain('spawn failed') + }, 30_000) + + it('danger-full-access background runs bypass confine and carry no facts', async () => { + const { executor, calls } = await setup() + const proc = executor.start(executor.resolve({ + command: 'echo full-bg', + sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' }, + })) + await proc.done + expect(calls).toHaveLength(0) + expect(proc.sandbox).toBeUndefined() + }, 30_000) +}) diff --git a/packages/bash/pwsh-sandbox/tsconfig.json b/packages/bash/pwsh-sandbox/tsconfig.json new file mode 100644 index 0000000000..55eac06470 --- /dev/null +++ b/packages/bash/pwsh-sandbox/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../bash/pwsh-local" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index dc18224dda..ab2dc874ce 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -30,7 +30,7 @@ describe('dsh-base bundle', () => { expect(rows.some(row => row.id === 'agent-loop')).toBe(true) }) - it('ships the Windows platform layer as the documented danger-full-access roster', () => { + it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const parsed = yaml.load( readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), @@ -44,30 +44,20 @@ describe('dsh-base bundle', () => { const disables = parsed .filter(patch => patch.disabled === true) .map(patch => patch.id) - // The POSIX-only sandboxed stacks leave the Windows roster as one unit: - // shell (bash-sandbox/tool-bash), the permission switcher it requires, - // the fs/sandbox policy stack whose OS runners do not exist on win32, - // and the approval service — nothing on Windows asks for approval, so - // the model is never told approval exists or that asks auto-reject. - expect(disables).toEqual( - expect.arrayContaining([ - 'bash-sandbox', - 'tool-bash', - 'permission', - 'ui-permission', - 'sandbox', - 'sandbox-policy', - 'fs-sandbox', - 'approval', - ]), - ) + // Only the POSIX bash stack is disabled: the Windows roster confines the + // pwsh executor through the ACL runner chain, so the sandbox/policy rows, + // the permission switcher, fs-sandbox, and the approval service all stay + // enabled exactly as on POSIX — only the shell is swapped. + expect(disables).toEqual(['bash-sandbox', 'tool-bash']) const inserted = parsed .flatMap(patch => patch.insert ?? []) .map(row => row.id) - expect(inserted).toEqual( - expect.arrayContaining(['pwsh-local', 'tool-pwsh', 'fs-local']), - ) - // Full danger-full-access degradation: no approval surface at all. - expect(parsed.find(patch => patch.id === 'approval')?.config).toBeUndefined() + expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh', 'fs-local']) + // The patch no longer touches the permission/approval surface at all. + expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined() + expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined() }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 1abd4ba568..66ed54112f 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -1,26 +1,19 @@ # The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers. Windows cannot run -# the POSIX-only sandboxed stacks, so this layer swaps the shipped bash stack -# for the PowerShell stack AND drops the whole permission surface: no OS -# runner exists on Windows (landlock/bwrap/seatbelt are POSIX-only), so any -# policy would be theater — the unconfined shell could bypass fs-only path -# rules with one command. Windows therefore degrades to danger-full-access: -# unconfined pwsh + unconfined fs (`dsh-fs-local`), no permission switcher -# (dsh-permission requires a confining executor), and no approval service — -# nothing in the roster asks for approval, and the model is never told -# approval exists or that requests are auto-rejected. -# The launcher reads THIS file from the base bundle package (never through -# dsh.bundle.patch — that field names the one universal layer). A Windows -# host that prefers bash or confinement overrides these rows through its -# profile or home cordis.patch.yml. -# The bash-restore recipe must be complete: disable pwsh-local and tool-pwsh -# AND re-enable bash-sandbox and tool-bash (plus permission/ui-permission only -# if the switcher is wanted) — both executors register the same 'bash' -# service, so re-enabling the bash rows while pwsh-local stays inserted fails -# loud at load on a duplicate registration. -# The ui-permission disable targets a row owned by dsh-web-app, not dsh-base: -# a base-only profile (e.g. the `dsh plugin --profile` default template) has -# no such row, and the no-match logs a harmless warning on every load. +# hosts, between the bundle layers and the user layers. Windows confines +# through the ACL restricted-token runner (the win32 chain of +# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped +# stack is the SANDBOXED PowerShell executor plus the full permission +# surface: sandbox/sandbox-policy enforce the file-effect policy, the +# permission switcher and the approval service run exactly as on POSIX, and +# fs-sandbox fences the in-process filesystem view. Only the POSIX bash +# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. +# A Windows host that prefers the unconfined local pwsh executor or full +# access overrides these rows through its profile or home cordis.patch.yml. +# The bash-restore recipe must be complete: disable pwsh-sandbox and +# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor +# families register the same 'bash' service, so re-enabling the bash rows +# while pwsh-sandbox stays inserted fails loud at load on a duplicate +# registration. - id: bash-sandbox disabled: true @@ -28,27 +21,9 @@ - id: tool-bash disabled: true -- id: permission - disabled: true - -- id: ui-permission - disabled: true - -- id: sandbox - disabled: true - -- id: sandbox-policy - disabled: true - -- id: fs-sandbox - disabled: true - -- id: approval - disabled: true - - insert: - - id: pwsh-local - name: '@deepseek-ai/dsh-pwsh-local' + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 2683ab3654..060026e284 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-sandbox-local", - "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed", + "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", "version": "0.0.1", "private": true, "type": "module", @@ -31,6 +31,7 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "node-addon-landlock-run": "0.0.0-test.0", "schemastery": "^3.18.0" }, diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 64e92d9bf2..22182095d5 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,12 +1,16 @@ /** * Local sandbox backend. It selects the platform runner chain (Linux bwrap then - * Landlock; macOS Seatbelt), functionally probes competing candidates once, and - * reports each wrap's enforcement and stderr classification facts. Missing or unusable - * confinement fails closed rather than returning the original argv. + * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes + * competing candidates once, and reports each wrap's enforcement and stderr + * classification facts. Missing or unusable confinement fails closed rather + * than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' import { LAUNCHER_BIN, LAUNCHER_FAILURE_EXIT, @@ -69,6 +73,27 @@ function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean return probe.status === 0 } +/** + * Functional windows-acl probe: run the runner in read-only mode (zero grants, + * no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created + * the restricted token and spawned the child under it. The win32 chain is a + * sole candidate, so the product never probes; the probe exists for override + * chains and mirrors the other rungs' shape. + */ +function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean { + const program = runnerInvocation[0] + if (program === undefined) return false + const probe = spawnSync(program, [ + ...runnerInvocation.slice(1), + '--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only', + '--', 'cmd', '/c', 'exit', '0', + ], { + timeout: timeoutMs, + stdio: 'ignore', + }) + return probe.status === 0 +} + /** Test seam: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -85,10 +110,14 @@ export interface SandboxInternals { landlockLauncher?: string /** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */ seatbeltExec?: string + /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ + windowsAclRunnerArgs?: string[] + /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ + probeWindowsAcl?: () => boolean } /** The chain's verdict: which runner confines, and how completely it enforces. */ -type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement } +type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } /** * The runner chain per platform — selection is BY PLATFORM first, probes @@ -102,11 +131,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: const PLATFORM_CHAINS: Record = { linux: ['bwrap', 'landlock'], darwin: ['seatbelt'], - // Reserved slot, deliberately empty: Windows support fills it with a confinement runner - // (AppContainer / restricted-token family, shipped from its own repository on the - // landlock-run template) plus a SelectedRunner['runner'] union member — the switches' - // assertNever guards then walk the implementer to every site. - win32: [], + // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl): + // a sole candidate, selected without a probe — its execution-time refusal + // fails closed through its stderr signature (windows-acl-run:) and exit 127. + win32: ['windows-acl'], } /** @@ -122,6 +150,9 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', + // The restricted token intersects every write access by construction, so + // the ACL runner governs every promised file effect — full enforcement. + 'windows-acl': 'full', } /** @@ -144,6 +175,9 @@ const DENIAL_SIGNATURES = { bwrap: ['read-only file system'], landlock: ['permission denied'], seatbelt: ['operation not permitted'], + // pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied."; + // node EACCES: "permission denied". + 'windows-acl': ['access is denied', 'access to the path', 'permission denied'], runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record @@ -152,7 +186,9 @@ const DENIAL_SIGNATURES = { * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. - * Keep the Landlock tuple aligned with the assembled snapshot fixture at + * The windows-acl runner prints `windows-acl-run: ` on every + * runner-side failure and exits 127. Keep the Landlock tuple aligned with the + * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ const RUNNER_FAILURE_RULES = { @@ -163,6 +199,7 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], + 'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record /** @@ -245,6 +282,14 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] + case 'windows-acl': return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Explicit, never GetTempPathW-defaulted: the runner grants exactly + // this directory (workspace-write) or nothing (read-only). + '--temp', tmpdir(), + '--mode', policy.mode, + ] default: return assertNever(runner) } } @@ -295,6 +340,11 @@ export class LocalSandboxProvider extends SandboxProvider { const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs)) return probe(this.seatbeltExec()) ? 'full' : 'unusable' } + case 'windows-acl': { + const probe = this.internals.probeWindowsAcl + ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)) + return probe() ? 'full' : 'unusable' + } default: return assertNever(runner) } } @@ -308,6 +358,21 @@ export class LocalSandboxProvider extends SandboxProvider { private seatbeltExec(): string { return this.internals.seatbeltExec ?? 'sandbox-exec' } + + /** + * The windows-acl runner argv prefix: the built lib/runner.js entry when + * present (production), else the package source through tsx (development). + * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps + * the same argv contract and only swaps these entries. + */ + private windowsAclRunnerInvocation(): string[] { + const override = this.internals.windowsAclRunnerArgs + if (override !== undefined) return override + const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + if (existsSync(builtEntry)) return [process.execPath, builtEntry] + const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) + return [process.execPath, '--import', 'tsx/esm', sourceEntry] + } } export default LocalSandboxProvider diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 74d4c2a8a1..5e12b37ad2 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -209,13 +209,10 @@ describe('the platform chains', () => { expect(probeSeatbelt).not.toHaveBeenCalled() }) - it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => { - // The slot exists so Windows support is an additive fill-in (chain entry - // + runner union member), never a redesign — and reserving it must not - // weaken the fail-closed end in the meantime. - const { sandbox } = await setup({}, { platform: 'win32' }) - expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) - }) + // The win32 chain's argv contract, denial dialect, and runner-failure rules + // live in @deepseek-ai/dsh-sandbox-windows-acl/tests/provider-chain.spec.ts + // (platform-independent assertions that run in every CI lane, including + // Windows where this package's POSIX-only suites are excluded). it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => { const probeBwrap = vi.fn(() => true) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml new file mode 100644 index 0000000000..d1ed2c0d43 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md +README.md: 34a9e7160bae3af93da17cae254202bc1b555967 +README.zh.md: 0ecfd146a2ef500aa34fe7b6314926a8daf802ab diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md new file mode 100644 index 0000000000..34a9e7160b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -0,0 +1,64 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +English | [中文](README.zh.md) + +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), built as the preparation layer for a Windows `SandboxProvider` (`workspace-write` / `read-only` modes). Linux/macOS backends live in [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/). + +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. + +## Usage + +```ts +import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // revokes all standing grants; reports every cleanup failure +``` + +Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. + +## The confinement runner + +The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: + +```sh +node runner.js --workspace

--temp --mode -- +``` + +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. + +Modes: +- `workspace-write`: the workspace and temp directories carry the orphan-SID Write grant; every other write is denied by the token intersection. +- `read-only`: STRICT zero grants — nothing is writable. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Documented behavior, not a prompt promise — the model-facing surface makes no sink claims for read-only mode. + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns. + +## Header verification + +All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts): + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory. + +## Verified boundaries (inherent to restricted tokens, not this port) + +- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. +- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. +- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. + +## Known Limitations and Deferred Work + +- **No `SandboxProvider` wiring yet** — this package is the primitives layer; the `ctx.sandbox.confine()` integration (spawn-side token application plus the `denialSignatures`/`runnerFailureRules` contract) is the next step and cannot reuse the argv-wrapping style of `dsh-sandbox-local` because the restricted token must be applied at `CreateProcess` time. +- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. +- **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Read-side confinement, network policy, and job-object kill-on-close are out of scope** for this layer and belong to the future provider design. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md new file mode 100644 index 0000000000..0ecfd146a2 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -0,0 +1,64 @@ +# @deepseek-ai/dsh-sandbox-windows-acl + +[English](README.md) | 中文 + +面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 Windows 端 `SandboxProvider`(`workspace-write` / `read-only` 模式)实装的准备层。Linux/macOS 后端见 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)。 + +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 + +## 用法 + +```ts +import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' + +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行 + +const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) +const { stdout, stderr, exitCode } = await child.wait() + +sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败 +``` + +本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 + +## 隔离 runner + +面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: + +```sh +node runner.js --workspace --temp --mode -- +``` + +runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 + +模式: +- `workspace-write`:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`:**严格零授权**——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。 + +`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。 + +## 头文件查证 + +所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)逐一核实,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(尺寸、偏移、枚举值、static_assert)交叉验证: + +```sh +g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe +``` + +模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。 + +## 已验证的边界(受限令牌固有,非本移植缺陷) + +- **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 +- **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。 +- **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 + +## 已知限制与后续工作 + +- **尚未接入 `SandboxProvider`** —— 本包是原语层;`ctx.sandbox.confine()` 的集成(在 spawn 侧应用受限令牌,并补齐 `denialSignatures`/`runnerFailureRules` 契约)是下一步。该集成不能沿用 `dsh-sandbox-local` 的 argv 包装风格,因为受限令牌必须在 `CreateProcess` 时生效。 +- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 +- **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **读侧隔离、网络策略、job-object 关闭即杀** 超出本层范围,留给未来的 provider 设计。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json new file mode 100644 index 0000000000..3e2afd8fb3 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-sandbox-windows-acl", + "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./runner": { + "types": "./lib/types/runner.d.ts", + "default": "./lib/runner.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/runner.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "koffi": "^3.1.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts new file mode 100644 index 0000000000..58020c4470 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -0,0 +1,113 @@ +/** + * ACL editing helpers: grant/revoke the orphan write SID on a directory via + * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with + * the failure handling the POC lacks). Every API call is checked and every + * failure is reported with the API name, the exact Win32 code, the formatted + * system text, and the affected path. + * @module @deepseek-ai/dsh-sandbox-windows-acl/acl + */ + +import { allocPtrSlot, decodePtr, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp): + * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, + * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. + * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which + * removes every ACE for the trustee. + */ +function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { + const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) + entry.writeUInt32LE(permissions, 0) // grfAccessPermissions + entry.writeUInt32LE(mode, 4) // grfAccessMode + entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation + entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm + entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType + entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID + return entry +} + +/** + * Grant `FILE_GENERIC_WRITE & ~READ_CONTROL` (displays as "Write") to the + * orphan SID on `path`, inheriting to subcontainers and objects. The directory + * must be owned by the caller (owner implicit WRITE_DAC) — same precondition + * as the POC. + */ +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), null, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', mergeResult, path) + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `null ACL for ${path}`) + + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + // Free the LocalAlloc'd ACL before any throw; capture both outcomes first. + const freed = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, path) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path})`) +} + +/** + * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * merge — other entries are preserved). Returns whether an ACE removal was + * attempted (false when the directory carries no DACL at all). + * + * Allocation contract (the POC's RevokeAccess, minus its missing checks): + * GetNamedSecurityInfoW returns the DACL pointer INSIDE the security + * descriptor allocation — only the descriptor may be LocalFree'd, and it must + * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL + * pointer itself corrupts the heap (verified the hard way). + */ +export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) + const oldAcl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + + if (oldAcl === null) { + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + } + return false + } + + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, newAclSlot) + if (mergeResult !== abi.ERROR_SUCCESS) { + if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + throwWin32(api, 'SetEntriesInAclW', mergeResult, `revokeWrite(${path})`) + } + const newAcl = decodePtr(newAclSlot) + if (newAcl === null) { + if (descriptor !== null) api.localFree(descriptor) + throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `revokeWrite(${path}): null new ACL`) + } + + // The descriptor block (oldAcl included) is dead after the merge — free it + // before applying, exactly like the POC. + const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null + const applyResult = api.setNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + null, null, newAcl, null, + ) + const freedNew = api.localFree(newAcl) + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `revokeWrite(${path})`) + if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) new ACL`) + return true +} diff --git a/packages/sandbox/sandbox-windows-acl/src/errors.ts b/packages/sandbox/sandbox-windows-acl/src/errors.ts new file mode 100644 index 0000000000..b57d6dd466 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/errors.ts @@ -0,0 +1,21 @@ +/** + * Fail-closed Win32 error type. Every backend API failure raises this with the + * API name and the exact Win32 code; the original POC silently ignored every + * failed call and would run children UNRESTRICTED (fail-open) — that is the + * failure mode this class exists to prevent. + * @module @deepseek-ai/dsh-sandbox-windows-acl/errors + */ + +export class Win32Error extends Error { + /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */ + readonly api: string + /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */ + readonly win32Code: number + + constructor(api: string, win32Code: number, detail?: string) { + super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`) + this.name = 'Win32Error' + this.api = api + this.win32Code = win32Code + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts new file mode 100644 index 0000000000..a2fefecdbb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -0,0 +1,307 @@ +/** + * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so + * non-Windows processes never open Win32 libraries. Every function signature + * below was verified against the MinGW Windows headers on this machine + * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h / + * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h); + * struct layouts are asserted at load time against verify/abi-probe.cpp. + * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi + */ + +import koffi from 'koffi' +import { Win32Error } from './errors.ts' +import * as abi from './win32-abi.ts' + +/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ +declare const nativePtr: unique symbol +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** True for NULL pointers, however koffi returns them (null or 0n). */ +export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { + return value === null || value === undefined || (value as bigint) === 0n +} + +type Ptr = ReturnType + +/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ +export interface StartupInfoInput { + cb: number + dwFlags: number + hStdInput: NativePtr + hStdOutput: NativePtr + hStdError: NativePtr +} + +/** Decoded PROCESS_INFORMATION (layout verified: size 24). */ +export interface ProcessInfoOutput { + hProcess: NativePtr | null + hThread: NativePtr | null + dwProcessId: number + dwThreadId: number +} + +export interface Win32Bindings { + // ---- process / token handles -------------------------------------------- + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number + closeHandle(handle: NativePtr): number + // ---- errors / diagnostics ------------------------------------------------ + getLastError(): number + formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number + // ---- memory -------------------------------------------------------------- + localAlloc(flags: number, bytes: number): NativePtr + localFree(memory: NativePtr): NativePtr + // ---- SIDs ---------------------------------------------------------------- + convertStringSidToSidW(stringSid: string, sid: NativePtr): number + convertSidToStringSidW(sid: NativePtr, stringSid: NativePtr): number + createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number + isValidSid(sid: NativePtr): number + getLengthSid(sid: NativePtr): number + copySid(length: number, destination: NativePtr, source: NativePtr): number + // ---- token information --------------------------------------------------- + getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + // ---- restricted token ---------------------------------------------------- + createRestrictedToken( + existing: NativePtr, flags: number, + disableCount: number, disableSids: null, + deletePrivilegeCount: number, privilegesToDelete: null, + restrictCount: number, restrictingSids: Buffer, + newToken: NativePtr, + ): number + // ---- ACL editing --------------------------------------------------------- + setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + setNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: null, group: null, dacl: NativePtr | null, sacl: null, + ): number + getNamedSecurityInfoW( + path: string, objectType: number, information: number, + owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, + ): number + // ---- environment / io ---------------------------------------------------- + getTempPathW(length: number, buffer: Buffer): number + createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number + setHandleInformation(handle: NativePtr, mask: number, flags: number): number + createProcessAsUserW( + token: NativePtr, applicationName: null, commandLine: string, + processAttributes: null, threadAttributes: null, + inheritHandles: number, creationFlags: number, environment: null, + currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, + ): number + readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number + peekNamedPipe( + pipe: NativePtr, buffer: null, size: number, + bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr, + ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number + getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number + resumeThread(thread: NativePtr): number + // ---- job object (runner kill-on-close) ----------------------------------- + createJobObjectW(attributes: null, name: null): NativePtr + setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number + assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // ---- console ------------------------------------------------------------- + // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): + // the runner survives console Ctrl+C so the child handles its own and the + // runner can clean up grants after the child exits. + setConsoleCtrlHandler(handler: null, add: number): number + getStdHandle(stdHandle: number): NativePtr +} + +const PVOID: Ptr = koffi.pointer('void') +const PPVOID: Ptr = koffi.pointer(PVOID) + +export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { + cb: 'uint32', + lpReserved: 'str16', + lpDesktop: 'str16', + lpTitle: 'str16', + dwX: 'uint32', + dwY: 'uint32', + dwXSize: 'uint32', + dwYSize: 'uint32', + dwXCountChars: 'uint32', + dwYCountChars: 'uint32', + dwFillAttribute: 'uint32', + dwFlags: 'uint32', + wShowWindow: 'uint16', + cbReserved2: 'uint16', + lpReserved2: koffi.pointer('uint8'), + hStdInput: PVOID, + hStdOutput: PVOID, + hStdError: PVOID, +}) + +export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { + hProcess: PVOID, + hThread: PVOID, + dwProcessId: 'uint32', + dwThreadId: 'uint32', +}) + +if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) { + throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`) +} +if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { + throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) +} + +/** Allocate one pointer-sized slot (for `T **` out-parameters). */ +export function allocPtrSlot(): NativePtr { + const value: unknown = koffi.alloc(PVOID, 1) + return value as NativePtr +} + +/** Allocate one uint32 slot. */ +export function allocUint32(): NativePtr { + const value: unknown = koffi.alloc('uint32', 1) + return value as NativePtr +} + +/** Write a uint32 value into a slot pointer. */ +export function encodeUint32(slot: NativePtr, value: number): void { + koffi.encode(slot, 'uint32', value) +} + +/** Decode the pointer stored in a pointer-sized slot (NULL becomes null). */ +export function decodePtr(slot: NativePtr): NativePtr | null { + const value: unknown = koffi.decode(slot, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** Decode a uint32 at a slot pointer. */ +export function decodeUint32(slot: NativePtr): number { + const value: unknown = koffi.decode(slot, 'uint32') + return value as number +} + +/** Decode a UTF-16 string at a pointer. */ +export function decodeStr16(ptr: NativePtr): string { + const value: unknown = koffi.decode(ptr, 'str16') + return value as string +} + +/** Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). */ +export function ptrAddress(ptr: NativePtr): bigint { + return koffi.address(ptr) +} + +/** Allocate a raw byte block (used for SID copies and variable-length arrays). */ +export function allocBytes(length: number): NativePtr { + const value: unknown = koffi.alloc('uint8', length) + return value as NativePtr +} + +/** Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). */ +export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { + const value: unknown = koffi.decode(buffer, offset, PVOID) + if (isNullPtr(value as NativePtr | null | undefined)) return null + return value as NativePtr +} + +/** Allocate a zeroed STARTUPINFOW. */ +export function allocStartupInfo(): NativePtr { + const value: unknown = koffi.alloc(STARTUPINFOW, 1) + return value as NativePtr +} + +/** Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). */ +export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { + koffi.encode(startupInfo, STARTUPINFOW, fields) +} + +/** Allocate a zeroed PROCESS_INFORMATION. */ +export function allocProcessInfo(): NativePtr { + const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) + return value as NativePtr +} + +/** Decode a PROCESS_INFORMATION after CreateProcessAsUserW. */ +export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { + const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) + return value as ProcessInfoOutput +} + +let cached: Win32Bindings | undefined + +function bindings(): Win32Bindings { + if (cached !== undefined) return cached + const kernel32 = koffi.load('kernel32.dll') + const advapi32 = koffi.load('advapi32.dll') + + // Each binding shape is verified by verify/abi-probe.cpp against the real + // Windows headers and exercised end-to-end by tests/probe.spec.ts; the + // single cast keeps the per-binding noise out of this table. + const bind = (lib: ReturnType, name: string, result: Ptr | string, args: Array): unknown => + lib.func('__stdcall', name, result, args) + + cached = { + openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]), + closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]), + getLastError: bind(kernel32, 'GetLastError', 'uint32', []), + formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]), + localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), + localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), + convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), + convertSidToStringSidW: bind(advapi32, 'ConvertSidToStringSidW', 'int', [PVOID, koffi.pointer('str16')]), + createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), + isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), + getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), + copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), + getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), + createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), + setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), + getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), + getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), + createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), + setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), + createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ + PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', + koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), + ]), + readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), + peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), + waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), + getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]), + resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]), + createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), + setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), + assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), + getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), + } as unknown as Win32Bindings + return cached +} + +/** Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). */ +export function win32(): Promise { + return Promise.resolve(bindings()) +} + +/** Turn a Win32 error code into readable text via FormatMessageW. */ +export function errorText(api: Win32Bindings, win32Code: number): string { + const buffer = Buffer.alloc(1024) + const length = api.formatMessageW( + abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS, + null, win32Code, 0, buffer, buffer.length / 2, null, + ) + if (length === 0) return '' + return buffer.subarray(0, length * 2).toString('utf16le').trim() +} + +/** + * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately + * after the failed call so GetLastError is not clobbered by other Win32 calls. + */ +export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { + const win32Code = api.getLastError() + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} + +/** Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). */ +export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { + throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts new file mode 100644 index 0000000000..77655881ca --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -0,0 +1,280 @@ +/** + * Windows ACL write-restriction sandbox backend for the DeepSeek Harness + * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ + * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED + * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only + * this sandbox instance adds to the target directories' DACLs — the + * intersection check then allows writes exactly where that SID has a Write + * ACE, and nowhere else. Unlike the POC, every API failure throws with the + * API name and exact Win32 code; a child is NEVER spawned unrestricted. + * + * Known boundaries (inherent to restricted tokens, not this port): + * - writes are restricted; reads, network, and process visibility are NOT + * (WRITE_RESTRICTED intersects only write accesses); + * - console isolation is unavailable — children share the host console + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with + * STATUS_DLL_INIT_FAILED under the restriction); + * - the temp directory and every writable directory must be owned by the + * caller (owner-implicit WRITE_DAC); + * - grants are standing ACE mutations on real directories — revoke them via + * dispose() before the process exits (the POC's documented + * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use + * this module's revoke instead). + * @module @deepseek-ai/dsh-sandbox-windows-acl + */ + +import { randomInt } from 'node:crypto' +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +import { grantWrite, revokeWrite } from './acl.ts' +import { Win32Error } from './errors.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' +import * as abi from './win32-abi.ts' + +export { quoteArg } from './spawn.ts' +export { Win32Error } from './errors.ts' + +export interface AclSandboxOptions { + /** Directories the confined child may write into (must exist and be caller-owned). */ + writableDirs: readonly string[] + /** + * Temp directory to also grant; defaults to GetTempPathW() at init time. + * Pass null for read-only confinement: NO temp grant (strict zero write + * allowance — not even the NUL device is writable, see README). + */ + tempDir?: string | null + /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ + writeSid?: string +} + +export interface AclSandboxSpawnOptions { + /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ + command: string + /** Arguments, quoted per CommandLineToArgvW rules. */ + args?: readonly string[] + /** Working directory; defaults to the caller's cwd. */ + cwd?: string + /** + * 'pipe' (default): capture stdout/stderr via anonymous pipes. + * 'inherit': the child inherits the caller's stdio directly (runner usage — + * bytes flow straight through), always wrapped in a kill-on-close job so the + * child dies with the caller; stdout/stderr in the result are empty. + */ + stdio?: 'pipe' | 'inherit' +} + +export interface AclSandboxChildResult { + stdout: Buffer + stderr: Buffer + exitCode: number +} + +export interface AclSandboxChild { + /** Child process id. */ + pid: number + /** Resolve stdout/stderr and the exit code once the child exits. */ + wait(): Promise +} + +function randomWriteSid(): string { + return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` +} + +function getTempPath(api: Win32Bindings): string { + const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) + const length = api.getTempPathW(buffer.length / 2, buffer) + if (length === 0) throwLastError(api, 'GetTempPathW') + return buffer.subarray(0, length * 2).toString('utf16le') +} + +/** + * One write-restricted sandbox instance: token + orphan-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes whatever was granted + * and throws; `dispose()` revokes all grants and reports every cleanup + * failure. + */ +export class AclSandbox { + readonly writableDirs: string[] + readonly writeSid: string + private readonly tempDirOption: string | null | undefined + private tempDirResolved: string | null | undefined + private api: Win32Bindings | undefined + private token: NativePtr | undefined + private writeSidPtr: NativePtr | undefined + private grantedPaths: string[] = [] + + constructor(options: AclSandboxOptions) { + this.writableDirs = options.writableDirs.map((directory) => { + const absolute = resolve(directory) + if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { + throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`) + } + return absolute + }) + this.tempDirOption = options.tempDir + this.writeSid = options.writeSid ?? randomWriteSid() + } + + /** Resolved temp directory (available after init; null when temp grants are disabled). */ + get tempDir(): string | null | undefined { + return this.tempDirResolved + } + + /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + async init(): Promise { + if (this.api !== undefined) throw new Error('AclSandbox is already initialized') + const api = await win32() + + const currentToken = openCurrentProcessToken(api) + try { + const sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + const writeSidPtr = parsedSid + + const tempDir = this.tempDirOption === null + ? null + : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + if (tempDir !== null) { + if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { + throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) + } + this.tempDirResolved = tempDir + } + + for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { + grantWrite(api, path, writeSidPtr) + this.grantedPaths.push(path) + } + const logonSid = findLogonSid(api, currentToken) + const restricted = createRestrictedToken( + api, currentToken, logonSid, writeSidPtr, + { + world: makeWellKnownSid(api, abi.WinWorldSid), + authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), + interactive: makeWellKnownSid(api, abi.WinInteractiveSid), + local: makeWellKnownSid(api, abi.WinLocalSid), + }, + ) + this.token = restricted + if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + this.api = api + } catch (error) { + // Best-effort close on the failure path (last error already captured in `error`). + api.closeHandle(currentToken) + // Fail-closed cleanup: never leave standing grants behind a failed init. + const cleanupFailures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + ) + } + throw error + } + } + + /** + * Spawn a process under the restricted token. Fails closed: throws on every + * Win32 failure; the child is never created unrestricted. With + * `stdio: 'inherit'` the child shares the caller's stdio directly and is + * placed in a kill-on-close job (dies with the caller). Call dispose() only + * after all children have exited — revoking grants under a live child + * removes its remaining write allowance. + */ + spawn(options: AclSandboxSpawnOptions): AclSandboxChild { + const api = this.api + const token = this.token + if (api === undefined || token === undefined) throw new Error('AclSandbox is not initialized: call init() first') + const args = options.args ?? [] + const cwd = options.cwd ?? process.cwd() + + if (options.stdio === 'inherit') { + const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd }) + let exitCodePromise: Promise | undefined + return { + pid: native.pid, + wait: async () => { + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + const exitCode = await exitCodePromise + if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job') + return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode } + }, + } + } + + const native = spawnSandboxed(api, token, { command: options.command, args, cwd }) + const stdout = drainPipe(api, native.stdoutRead) + const stderr = drainPipe(api, native.stderrRead) + // waitForExit is deliberately NOT started here: WaitForSingleObject blocks + // the thread and would starve the drains while the child is still running + // (pipe-buffer deadlock). The drains resolve only after the child closed + // its pipe ends — by then the wait returns immediately. + let exitCodePromise: Promise | undefined + return { + pid: native.pid, + wait: async () => { + const stdoutBuffer = await stdout + const stderrBuffer = await stderr + exitCodePromise ??= Promise.resolve(waitForExit(api, native.process)) + return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise } + }, + } + } + + /** Revoke all standing grants, free the SID, close the token; reports every cleanup failure. */ + dispose(): void { + const api = this.api + if (api === undefined) return + const failures: unknown[] = [] + const writeSidPtr = this.writeSidPtr + if (writeSidPtr !== undefined) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = api.localFree(writeSidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + } + const token = this.token + if (token !== undefined) { + try { + if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token') + } catch (error) { + failures.push(error) + } + } + this.api = undefined + this.token = undefined + this.writeSidPtr = undefined + this.grantedPaths = [] + if (failures.length > 0) { + throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts new file mode 100644 index 0000000000..35ea265a4b --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`. + * @module @deepseek-ai/dsh-sandbox-windows-acl/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-windows-acl-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or + * mutable data relation beyond the fail-closed contracts it enforces at each + * Win32 call boundary. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts new file mode 100644 index 0000000000..02fd416f7f --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -0,0 +1,135 @@ +/** + * The windows-acl confinement runner: the argv-prefix wrapper the sandbox + * seam spawns in place of the caller's command. It creates the + * WRITE_RESTRICTED token with the orphan-SID allowlist, spawns the wrapped + * argv under it with the CALLER'S stdio inherited (bytes flow straight + * through), mirrors the child's exit code, and revokes all grants on exit. + * + * Stable argv contract (the seam builds it; a native-exe replacement would + * keep the same contract): + * [node, runner.js, '--workspace', , '--temp', , + * '--mode', , '--', ] + * + * Modes: + * - workspace-write: the workspace and temp directories carry the orphan-SID + * Write grant; every other write is denied by the token intersection. + * - read-only: STRICT zero grants — no directory is writable, not even the + * NUL device (`> $null` fails with access denied); documented in README. + * + * Failure contract: every runner-side failure (bad args, missing + * directories, token/grant/spawn errors) prints `windows-acl-run: ` + * to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that + * signature. The child is NEVER spawned unrestricted. + * @module @deepseek-ai/dsh-sandbox-windows-acl/runner + */ + +import { existsSync, statSync } from 'node:fs' + +import { win32 } from './ffi.ts' +import { AclSandbox } from './index.ts' + +const RUNNER_SIGNATURE = 'windows-acl-run' +const RUNNER_FAILURE_EXIT = 127 + +class RunnerFailure extends Error {} + +/** Print the runner-failure signature line and unwind. */ +function fail(detail: string): never { + process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`) + throw new RunnerFailure(detail) +} + +interface ParsedArgs { + workspace: string + temp: string + mode: 'read-only' | 'workspace-write' + command: string + args: string[] +} + +function parseArgs(raw: string[]): ParsedArgs { + let workspace: string | undefined + let temp: string | undefined + let mode: string | undefined + let index = 0 + for (; index < raw.length; index++) { + const token = raw[index] + if (token === '--') { + index++ + break + } + index++ + const value = raw[index] + if (value === undefined) fail(`missing value after ${token}`) + switch (token) { + case '--workspace': workspace = value; break + case '--temp': temp = value; break + case '--mode': mode = value; break + default: fail(`unknown argument: ${token}`) + } + } + if (workspace === undefined) fail('missing --workspace') + if (temp === undefined) fail('missing --temp') + if (mode !== 'read-only' && mode !== 'workspace-write') fail(`unknown mode: ${String(mode)}`) + const argv = raw.slice(index) + const command = argv[0] + if (command === undefined) fail('missing command after --') + return { workspace, temp, mode, command, args: argv.slice(1) } +} + +function requireDirectory(label: string, path: string): void { + if (!existsSync(path) || !statSync(path).isDirectory()) { + fail(`${label} is not an existing directory: ${path}`) + } +} + +async function main(): Promise { + const parsed = parseArgs(process.argv.slice(2)) + // Both directories are validated in both modes: a provider bug that passes + // a bogus root must fail loudly at the runner boundary, never mid-child. + requireDirectory('--workspace', parsed.workspace) + requireDirectory('--temp', parsed.temp) + + const api = await win32() + // Ignore this process's own CTRL+C: the confined child (same console) keeps + // handling its own; the runner must survive to revoke grants and mirror the + // child's exit code. + if (api.setConsoleCtrlHandler(null, 1) === 0) { + fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) + } + + const sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + }) + await sandbox.init() + + try { + const child = sandbox.spawn({ + command: parsed.command, + args: parsed.args, + stdio: 'inherit', + }) + const result = await child.wait() + return result.exitCode + } finally { + // Cleanup failures must not mask the child's exit code: report and keep going. + try { + sandbox.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } +} + +main().then( + (exitCode) => { + process.exitCode = exitCode + }, + (error: unknown) => { + if (!(error instanceof RunnerFailure)) { + process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`) + } + process.exitCode = RUNNER_FAILURE_EXIT + }, +) diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts new file mode 100644 index 0000000000..58571257a1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -0,0 +1,296 @@ +/** + * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with + * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then + * asynchronous pipe draining and exit waiting. Console isolation + * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this + * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED + * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is + * pipe-based and unaffected; the child shares the host console. + * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn + */ + +import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Quote one argument per the CommandLineToArgvW parsing rules (backslash + * escaping only before quotes; a trailing backslash before the closing quote + * is doubled). + */ +export function quoteArg(argument: string): string { + if (argument === '') return '""' + if (!/[\s"]/u.test(argument)) return argument + let quoted = '"' + for (let index = 0; index < argument.length; index++) { + let backslashes = 0 + while (index < argument.length && argument.charAt(index) === '\\') { + backslashes++ + index++ + } + if (index < argument.length && argument.charAt(index) === '"') { + quoted += '\\'.repeat(backslashes * 2 + 1) + '"' + } else { + quoted += '\\'.repeat(backslashes) + (index < argument.length ? argument.charAt(index) : '') + } + } + return quoted + '"' +} + +/** Build the single command line CreateProcess parses from program + argv. */ +export function buildCommandLine(program: string, args: readonly string[]): string { + return [program, ...args].map(quoteArg).join(' ') +} + +interface PipePair { + read: NativePtr + write: NativePtr +} + +function createPipe(api: Win32Bindings): PipePair { + const readSlot = allocPtrSlot() + const writeSlot = allocPtrSlot() + if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe') + const read = decodePtr(readSlot) + const write = decodePtr(writeSlot) + if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle') + return { read, write } +} + +function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', label) + } +} + +export interface SpawnedNative { + pid: number + process: NativePtr + stdoutRead: NativePtr + stderrRead: NativePtr +} + +/** + * Create a process under the restricted token with piped stdio. The child's + * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends + * are returned for draining. + */ +export function spawnSandboxed( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedNative { + const stdIn = createPipe(api) + const stdOut = createPipe(api) + const stdErr = createPipe(api) + // Child side of each pipe must be inheritable (POC lines 262-268). + setInheritable(api, stdIn.read, 'stdin read end') + setInheritable(api, stdOut.write, 'stdout write end') + setInheritable(api, stdErr.write, 'stderr write end') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn.read, + hStdOutput: stdOut.write, + hStdError: stdErr.write, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: required for redirection + 0, // no creation flags: suspended/no-window variants are unusable under the restriction + null, options.cwd, + startupInfo, processInfo, + ) + // Capture the failure before CloseHandle calls clobber GetLastError. + if (created === 0) throwLastError(api, 'CreateProcessAsUserW', `command: ${options.command}, cwd: ${options.cwd}`) + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + // Host-side cleanup: child handles are now duplicated in the child; the + // host closes its copies so ReadFile sees EOF when the child exits. + api.closeHandle(stdIn.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.write) + api.closeHandle(stdIn.write) + api.closeHandle(threadHandle) + + return { + pid: info.dwProcessId, + process: processHandle, + stdoutRead: stdOut.read, + stderrRead: stdErr.read, + } +} + +/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */ +export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { + const chunks: Buffer[] = [] + for (;;) { + const bytesReadSlot = allocUint32() + const totalAvailSlot = allocUint32() + const leftThisMessageSlot = allocUint32() + const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) + if (peeked === 0) { + const win32Code = api.getLastError() + if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF + throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`) + } + const available = decodeUint32(totalAvailSlot) + if (available > 0) { + const chunk = Buffer.alloc(available) + const readSlot = allocUint32() + if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) { + throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`) + } + chunks.push(chunk.subarray(0, decodeUint32(readSlot))) + } + await new Promise(resolve => setImmediate(resolve)) + } + api.closeHandle(handle) + return Buffer.concat(chunks) +} + +/** + * Wait for process exit and return its exit code. Call only after both drains + * have resolved — the drains finish when the child closed its pipe ends, i.e. + * the child has already exited, so this wait returns immediately. Calling it + * earlier would block the event loop and starve the drains (the pipe-buffer + * deadlock the POC comments warn about). + */ +export function waitForExit(api: Win32Bindings, process: NativePtr): number { + const waitResult = api.waitForSingleObject(process, abi.INFINITE) + if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject') + const exitCodeSlot = allocUint32() + if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess') + api.closeHandle(process) + return decodeUint32(exitCodeSlot) +} + +/** + * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at + * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout + * verified by abi-probe.cpp). When the caller dies with the job handle open, + * Windows terminates every process in the job — the orphan-child backstop. + * The caller keeps the returned handle open for the child's lifetime. + */ +function createKillOnCloseJob(api: Win32Bindings): NativePtr { + const job = api.createJobObjectW(null, null) + if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW') + const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE) + information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET) + if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'SetInformationJobObject', win32Code) + } + return job +} + +export interface SpawnedInherited { + pid: number + process: NativePtr + /** Kill-on-close job the child was placed in; caller closes it after the child exits. */ + job: NativePtr +} + +/** + * Create a process under the restricted token whose stdio passes straight + * through to the caller's pipes. This is the runner shape: the harness spawns + * the runner with piped stdio, and the runner's confined child writes to + * those same pipes. + * + * Node clears the inheritability of its stdio handles at startup + * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit + * bit around the call (libuv instead duplicates the handles; re-enabling is + * equivalent here and cheaper) and pass them explicitly via + * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles + * ("The handle is invalid", verified the hard way). The child starts + * suspended so it can be assigned to a kill-on-close job before it runs. + */ +export function spawnSandboxedInherited( + api: Win32Bindings, + token: NativePtr, + options: { command: string; args: readonly string[]; cwd: string }, +): SpawnedInherited { + const job = createKillOnCloseJob(api) + const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE) + const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE) + const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE) + if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) { + api.closeHandle(job) + throwLastError(api, 'GetStdHandle', 'null standard handle') + } + + const makeInheritable = (handle: NativePtr, label: string): void => { + if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) { + throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`) + } + } + const restoreInherit = (handle: NativePtr): void => { + // Best-effort hygiene: the runner spawns nothing else; failures here must + // not mask the child outcome, so the result is deliberately unchecked. + api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0) + } + makeInheritable(stdIn, 'stdin') + makeInheritable(stdOut, 'stdout') + makeInheritable(stdErr, 'stderr') + + const startupInfo = allocStartupInfo() + encodeStartupInfo(startupInfo, { + cb: abi.STARTUPINFOW_SIZE, + dwFlags: abi.STARTF_USESTDHANDLES, + hStdInput: stdIn, + hStdOutput: stdOut, + hStdError: stdErr, + }) + + const processInfo = allocProcessInfo() + const commandLine = buildCommandLine(options.command, options.args) + const created = api.createProcessAsUserW( + token, null, commandLine, + null, null, + 1, // bInheritHandles: the re-enabled std handles must be inheritable + abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution + null, options.cwd, + startupInfo, processInfo, + ) + restoreInherit(stdIn) + restoreInherit(stdOut) + restoreInherit(stdErr) + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(job) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } + + const info = decodeProcessInfo(processInfo) + const processHandle = info.hProcess + const threadHandle = info.hThread + if (processHandle === null || threadHandle === null) { + api.closeHandle(job) + throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`) + } + + if (api.assignProcessToJobObject(job, processHandle) === 0) { + const win32Code = api.getLastError() + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) + } + if (api.resumeThread(threadHandle) === 0xFFFFFFFF) throwLastError(api, 'ResumeThread', `pid ${info.dwProcessId}`) + api.closeHandle(threadHandle) + + return { pid: info.dwProcessId, process: processHandle, job } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts new file mode 100644 index 0000000000..e1c0f2dc2a --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -0,0 +1,138 @@ +/** + * Restricted-token construction: open the current process token, extract its + * logon SID, build the well-known SIDs, and call CreateRestrictedToken with + * the POC's restricting-SID allowlist. Every API call is checked; any failure + * throws with the API name and the exact Win32 code — the original POC ignored + * all of these and silently ran children with the FULL, unrestricted token. + * @module @deepseek-ai/dsh-sandbox-windows-acl/token + */ + +import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' +import * as abi from './win32-abi.ts' + +/** + * Open the current process's access token with the rights + * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token + * handle is obtained through a real OpenProcess handle because the + * GetCurrentProcess() pseudo-handle is not addressable through koffi). + */ +export function openCurrentProcessToken(api: Win32Bindings): NativePtr { + const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) + if (isNullPtr(processHandle)) throwLastError(api, 'OpenProcess', `pid ${process.pid}`) + + const tokenSlot = allocPtrSlot() + const opened = api.openProcessToken( + processHandle, + abi.TOKEN_QUERY | abi.TOKEN_DUPLICATE | abi.TOKEN_ADJUST_DEFAULT | abi.TOKEN_ASSIGN_PRIMARY, + tokenSlot, + ) + if (opened === 0) { + const win32Code = api.getLastError() + api.closeHandle(processHandle) // best-effort on the error path + throwWin32(api, 'OpenProcessToken', win32Code, `pid ${process.pid}`) + } + if (api.closeHandle(processHandle) === 0) throwLastError(api, 'CloseHandle', 'OpenProcess process handle') + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'OpenProcessToken', api.getLastError(), 'null token handle') + return token +} + +/** + * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute + * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and + * other per-logon objects; the POC extracts it the same way. + */ +export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenGroups, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenGroups size query') + if (needed < abi.TOKEN_GROUPS_OFFSET) throwWin32(api, 'GetTokenInformation', api.getLastError(), `implausible TokenGroups size ${needed}`) + + const groups = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenGroups, groups, groups.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenGroups') + } + const groupCount = groups.readUInt32LE(0) + for (let index = 0; index < groupCount; index++) { + const sidPtr = decodePtrAt(groups, abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE) + const attributes = groups.readUInt32LE(abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE + 8) + // >>> 0: JS bitwise & is signed 32-bit; SE_GROUP_LOGON_ID has bit 31 set. + const isLogonId = ((attributes & abi.SE_GROUP_LOGON_ID) >>> 0) === (abi.SE_GROUP_LOGON_ID >>> 0) + if (sidPtr === null || !isLogonId) continue + const sidLength = api.getLengthSid(sidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', `logon SID group ${index}`) + const copy = allocBytes(sidLength) + if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, 'CopySid', `logon SID group ${index}`) + return copy + } + throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) +} + +/** Create one well-known SID (68-byte buffer) and assert its validity. */ +export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { + const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) + const sizeSlot = allocUint32() + encodeUint32(sizeSlot, abi.SECURITY_MAX_SID_SIZE) + if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) { + throwLastError(api, 'CreateWellKnownSid', `type ${type}`) + } + if (api.isValidSid(sid) === 0) throwLastError(api, 'IsValidSid', `CreateWellKnownSid type ${type}`) + return sid +} + +/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ +function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { + const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) + sids.forEach((sid, index) => { + buffer.writeBigUInt64LE(ptrAddress(sid), abi.SID_AND_ATTRIBUTES_SIZE * index) + }) + return buffer +} + +export interface RestrictingSidSet { + world: NativePtr + authUser: NativePtr + interactive: NativePtr + local: NativePtr +} + +/** + * Create the write-restricted token. Ordering matters: EVERYONE first (the + * POC's note — the intersection check hits it on most objects), then the + * logon SID, Authenticated Users, INTERACTIVE, LOCAL, and finally the orphan + * write SID that forms the write allowlist. S-1-2-1 (console logon) is + * intentionally absent: see win32-abi.ts for the verified failure modes. + * FAILS CLOSED: any failure throws — never spawn unrestricted. + */ +export function createRestrictedToken( + api: Win32Bindings, + currentToken: NativePtr, + logonSid: NativePtr, + writeSid: NativePtr, + known: RestrictingSidSet, +): NativePtr { + const restrictingSids = buildRestrictingSids([ + known.world, + logonSid, + known.authUser, + known.interactive, + known.local, + writeSid, + ]) + const tokenSlot = allocPtrSlot() + const created = api.createRestrictedToken( + currentToken, + abi.DISABLE_MAX_PRIVILEGE | abi.LUA_TOKEN | abi.WRITE_RESTRICTED, + 0, null, // no SIDs disabled + 0, null, // no privileges deleted + restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE, + restrictingSids, + tokenSlot, + ) + if (created === 0) throwLastError(api, 'CreateRestrictedToken', `restricting SIDs: ${restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE}`) + const token = decodePtr(tokenSlot) + if (token === null) throwWin32(api, 'CreateRestrictedToken', api.getLastError(), 'null token handle') + return token +} diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts new file mode 100644 index 0000000000..97e5322bbf --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -0,0 +1,136 @@ +/** + * Windows ABI constants for the ACL-sandbox backend. + * + * Every value was verified against the actual MinGW Windows headers on this + * machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at + * runtime by verify/abi-probe.cpp (same numbers; static_asserts passed). + * Regenerate the probe with: + * g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe + * + * The port intentionally excludes two pieces of the original POC + * (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified + * empirically on Windows 11 build 26200: + * - S-1-2-1 (console logon SID) in the restricting list: the POC created it + * via CreateWellKnownSid(WinLocalLogonSid) which fails here with + * ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes + * CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the + * correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child + * then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever + * CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used. + * - Console isolation: under this restriction scheme a hidden console is not + * attainable, so children share the host console (stdio redirection is + * pipe-based and unaffected). + * @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi + */ + +// ---- winnt.h --------------------------------------------------------------- + +// TOKEN_* access rights (winnt.h lines ~3928) +export const TOKEN_ASSIGN_PRIMARY = 0x0001 +export const TOKEN_DUPLICATE = 0x0002 +export const TOKEN_QUERY = 0x0008 +export const TOKEN_ADJUST_DEFAULT = 0x0080 + +// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +export const SE_GROUP_LOGON_ID = 0xC0000000 + +// Generic file access (winnt.h lines ~5893-5913): +// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES +// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +export const FILE_GENERIC_WRITE = 0x00120116 +// What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as +// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 + +// CreateRestrictedToken flags (winnt.h lines ~4284) +export const DISABLE_MAX_PRIVILEGE = 0x1 +export const LUA_TOKEN = 0x4 +export const WRITE_RESTRICTED = 0x8 + +// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +export const WinWorldSid = 1 +export const WinLocalSid = 2 +export const WinInteractiveSid = 11 +export const WinAuthenticatedUserSid = 17 + +// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +export const TokenGroups = 2 + +// SECURITY_INFORMATION (winnt.h line ~4293) +export const DACL_SECURITY_INFORMATION = 0x00000004 + +// PROCESS access rights (winnt.h lines ~4364) +export const PROCESS_QUERY_INFORMATION = 0x0400 + +// ---- accctrl.h ------------------------------------------------------------- + +// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +export const SE_FILE_OBJECT = 1 + +// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +export const TRUSTEE_IS_UNKNOWN = 0 +export const TRUSTEE_IS_SID = 0 +export const NO_MULTIPLE_TRUSTEE = 0 + +// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +export const GRANT_ACCESS = 1 +export const REVOKE_ACCESS = 4 + +// grfInheritance (accctrl.h lines ~137-142) +export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + +// ---- winbase.h ------------------------------------------------------------- + +export const STARTF_USESTDHANDLES = 0x00000100 +export const HANDLE_FLAG_INHERIT = 0x1 +export const INFINITE = 0xFFFFFFFF +export const MAX_PATH = 260 +// winbase.h line ~410: the confined child starts suspended so the runner can +// assign it to the kill-on-close job before any of its code runs. +export const CREATE_SUSPENDED = 0x4 +// winbase.h lines ~497-499: GetStdHandle selectors. +export const STD_INPUT_HANDLE = -10 +export const STD_OUTPUT_HANDLE = -11 +export const STD_ERROR_HANDLE = -12 + +// FormatMessageW flags (winbase.h lines ~1446-1469) +export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 + +// ---- error codes ----------------------------------------------------------- + +export const ERROR_SUCCESS = 0 +export const ERROR_INSUFFICIENT_BUFFER = 122 +export const ERROR_BROKEN_PIPE = 109 +export const ERROR_NO_DATA = 232 + +// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- + +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last +// job handle closes — the orphan-child backstop for the runner design. +export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +export const JobObjectExtendedLimitInformation = 9 +// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 +// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION +// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), +// verified by abi-probe. +export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 + +// ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- + +export const SECURITY_MAX_SID_SIZE = 68 +/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ +export const SID_AND_ATTRIBUTES_SIZE = 16 +/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */ +export const TOKEN_GROUPS_OFFSET = 8 +/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */ +export const EXPLICIT_ACCESS_W_SIZE = 48 +/** Trustee offset inside EXPLICIT_ACCESS_W. */ +export const TRUSTEE_W_OFFSET = 16 +/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ +export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +export const STARTUPINFOW_SIZE = 104 +export const PROCESS_INFORMATION_SIZE = 24 diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts new file mode 100644 index 0000000000..e370316b79 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -0,0 +1,97 @@ +/** + * End-to-end probe of the ACL write-restriction sandbox, using the same + * probes as the POC verification harness: the confined child must be able to + * write into the granted target and temp directories, must be DENIED writing + * anywhere else, and (documented boundary) may still READ outside — the + * WRITE_RESTRICTED token intersects write accesses only. + * + * The escape target sits in its own scratch dir under the system temp + * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never + * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the + * whole real temp tree) and the writable dir is a separate mkdtemp directory + * that contains neither sibling. Nothing under the user profile is touched. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { AclSandbox } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + let sandbox: AclSandbox + + beforeAll(async () => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + // tempDir is passed explicitly: GetTempPathW reads the native environment + // block, which host runtimes (vitest worker pools) may not keep in sync + // with process.env — and a real-temp grant would inherit over every + // temp subdirectory, including this test's scratch dir. + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp }) + await sandbox.init() + }) + + afterAll(() => { + sandbox.dispose() + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('allows writes only in granted directories and denies the escape write', async () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const child = sandbox.spawn({ + command: 'pwsh', + args: ['/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe], + cwd: writableDir, + }) + const result = await child.wait() + const output = result.stdout.toString('utf8') + result.stderr.toString('utf8') + + expect(result.exitCode, `child output:\n${output}`).toBe(0) + expect(output, `child output:\n${output}`).toContain('TARGET-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('TEMP-WRITE: OK') + expect(output, `child output:\n${output}`).toContain('ESCAPE-WRITE: DENIED') + // Documented boundary: WRITE_RESTRICTED intersects write accesses only, + // so reads outside the allowlist still succeed. + expect(output, `child output:\n${output}`).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { + // A malformed SID makes ConvertStringSidToSidW fail; init must throw + // before any grant is applied and never spawn unrestricted. + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1' }) + await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts new file mode 100644 index 0000000000..f2737948ca --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -0,0 +1,58 @@ +/** + * The win32 chain's argv contract, denial dialect, and runner-failure rules, + * exercised through the REAL LocalSandboxProvider.confine() with an injected + * platform and runner argv prefix. Platform-independent assertions: they run + * in every CI lane (Windows included, where sandbox-local's own POSIX-only + * suites are excluded) — the end-to-end runner behavior lives in + * runner.spec.ts on win32 hosts. + */ + +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' + +const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } +const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + +async function setup(internals: LocalSandboxProvider['internals']) { + const ctx = new Context() + await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = internals + return sandbox +} + +describe('windows-acl win32 chain (LocalSandboxProvider)', () => { + it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const sandbox = await setup({ + platform: 'win32', + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + probeWindowsAcl, + }) + const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + // A sole candidate is selected unprobed. + expect(probeWindowsAcl).not.toHaveBeenCalled() + }) + + it('read-only: same runner and contract, read-only mode flag', async () => { + const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts new file mode 100644 index 0000000000..f188423425 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -0,0 +1,107 @@ +/** + * End-to-end runner tests: spawn the REAL runner entry through tsx (exactly + * the argv shape dsh-sandbox-local's confine() builds), with piped stdio + * inherited through the runner into the confined child — the same chain a + * production confined execution walks. + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const isWin32 = process.platform === 'win32' +const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) + +function pwshAvailable(): boolean { + try { + spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +function runRunner(args: string[], timeoutMs = 30_000) { + return spawnSync(process.execPath, ['--import', 'tsx/esm', runnerEntry, ...args], { + timeout: timeoutMs, + encoding: 'utf8', + }) +} + +describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { + let scratchRoot!: string + let writableDir!: string + let isolatedTemp!: string + let secretFile!: string + let escapeFile!: string + + beforeAll(() => { + scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) + writableDir = join(scratchRoot, 'writable') + mkdirSync(writableDir) + isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-')) + secretFile = join(scratchRoot, 'secret.txt') + writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') + escapeFile = join(scratchRoot, 'escaped.txt') + }) + + afterAll(() => { + rmSync(scratchRoot, { recursive: true, force: true }) + rmSync(isolatedTemp, { recursive: true, force: true }) + }) + + it('workspace-write: the confined child writes granted directories only', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('TARGET-WRITE: OK') + expect(result.stdout).toContain('TEMP-WRITE: OK') + expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(existsSync(escapeFile)).toBe(false) + expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) + }, 30_000) + + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine', () => { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + `try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, + `try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + // The NUL device is a securable object: strict zero grants deny it too. + 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', + // PowerShell's $null redirection discards without opening NUL — must keep working. + 'echo hi > $null;\'DOLLAR-NULL: OK\';', + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('TARGET-WRITE: DENIED') + expect(result.stdout).toContain('TEMP-WRITE: DENIED') + expect(result.stdout).toContain('NUL-WRITE: DENIED') + expect(result.stdout).toContain('DOLLAR-NULL: OK') + expect(result.stdout).toContain('SECRET-READ: OK') + expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) + }, 30_000) + + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { + const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) + expect(result.status).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + }, 15_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json new file mode 100644 index 0000000000..e882ed2d72 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sandbox/sandbox-windows-acl/tsdown.config.ts b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts new file mode 100644 index 0000000000..7de4ede1e1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +// The confinement runner builds as its own entry (path-loaded by +// dsh-sandbox-local's win32 chain), inlining the sandbox primitives while +// koffi stays an external native require — the same shape as +// directory-picker-native's worker entry. +export default defineConfig({ + entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', runner: 'lib/types/runner.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp new file mode 100644 index 0000000000..914326acdc --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -0,0 +1,177 @@ +// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows +// headers on this machine. These numbers are the source of truth for the +// koffi FFI definitions in the Node.js port. +#include +#include +#include +#include +#include + +#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr)) + +int wmain() +{ + P(sizeof(void*)); + P(sizeof(HANDLE)); + P(sizeof(DWORD)); + P(sizeof(WORD)); + P(sizeof(BOOL)); + + P(sizeof(STARTUPINFOW)); + P(offsetof(STARTUPINFOW, cb)); + P(offsetof(STARTUPINFOW, lpReserved)); + P(offsetof(STARTUPINFOW, lpDesktop)); + P(offsetof(STARTUPINFOW, lpTitle)); + P(offsetof(STARTUPINFOW, dwX)); + P(offsetof(STARTUPINFOW, dwY)); + P(offsetof(STARTUPINFOW, dwXSize)); + P(offsetof(STARTUPINFOW, dwYSize)); + P(offsetof(STARTUPINFOW, dwXCountChars)); + P(offsetof(STARTUPINFOW, dwYCountChars)); + P(offsetof(STARTUPINFOW, dwFillAttribute)); + P(offsetof(STARTUPINFOW, dwFlags)); + P(offsetof(STARTUPINFOW, wShowWindow)); + P(offsetof(STARTUPINFOW, cbReserved2)); + P(offsetof(STARTUPINFOW, lpReserved2)); + P(offsetof(STARTUPINFOW, hStdInput)); + P(offsetof(STARTUPINFOW, hStdOutput)); + P(offsetof(STARTUPINFOW, hStdError)); + + P(sizeof(PROCESS_INFORMATION)); + P(offsetof(PROCESS_INFORMATION, hProcess)); + P(offsetof(PROCESS_INFORMATION, hThread)); + P(offsetof(PROCESS_INFORMATION, dwProcessId)); + P(offsetof(PROCESS_INFORMATION, dwThreadId)); + + P(sizeof(SECURITY_ATTRIBUTES)); + P(offsetof(SECURITY_ATTRIBUTES, nLength)); + P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor)); + P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle)); + + P(sizeof(TRUSTEE_W)); + P(offsetof(TRUSTEE_W, pMultipleTrustee)); + P(offsetof(TRUSTEE_W, MultipleTrusteeOperation)); + P(offsetof(TRUSTEE_W, TrusteeForm)); + P(offsetof(TRUSTEE_W, TrusteeType)); + P(offsetof(TRUSTEE_W, ptstrName)); + + P(sizeof(EXPLICIT_ACCESS_W)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions)); + P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode)); + P(offsetof(EXPLICIT_ACCESS_W, grfInheritance)); + P(offsetof(EXPLICIT_ACCESS_W, Trustee)); + + P(sizeof(SID_AND_ATTRIBUTES)); + P(offsetof(SID_AND_ATTRIBUTES, Sid)); + P(offsetof(SID_AND_ATTRIBUTES, Attributes)); + + P(sizeof(TOKEN_GROUPS)); + P(offsetof(TOKEN_GROUPS, GroupCount)); + P(offsetof(TOKEN_GROUPS, Groups)); + + P(sizeof(TOKEN_MANDATORY_LABEL)); + + P(sizeof(SID)); + P(SECURITY_MAX_SID_SIZE); + P(SID_MAX_SUB_AUTHORITIES); + P(SID_REVISION); + + P(TOKEN_ASSIGN_PRIMARY); + P(TOKEN_DUPLICATE); + P(TOKEN_QUERY); + P(TOKEN_ADJUST_DEFAULT); + + P(SE_GROUP_LOGON_ID); + P(SE_GROUP_INTEGRITY); + P(SE_GROUP_INTEGRITY_ENABLED); + + P(FILE_GENERIC_WRITE); + P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); + P(STANDARD_RIGHTS_WRITE); + + P(DISABLE_MAX_PRIVILEGE); + P(SANDBOX_INERT); + P(LUA_TOKEN); + P(WRITE_RESTRICTED); + + P((int)WinWorldSid); + P((int)WinLocalSid); + P((int)WinInteractiveSid); + P((int)WinAuthenticatedUserSid); + P((int)WinLocalLogonSid); + P((int)WinConsoleLogonSid); + + P((int)TokenUser); + P((int)TokenGroups); + P((int)TokenIntegrityLevel); + + P((int)SE_FILE_OBJECT); + P(DACL_SECURITY_INFORMATION); + + P((int)TRUSTEE_IS_UNKNOWN); + P((int)TRUSTEE_IS_SID); + P((int)NOT_USED_ACCESS); + P((int)GRANT_ACCESS); + P((int)REVOKE_ACCESS); + P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(OBJECT_INHERIT_ACE); + P(CONTAINER_INHERIT_ACE); + + P(CREATE_SUSPENDED); + P(CREATE_NO_WINDOW); + P(DETACHED_PROCESS); + P(CREATE_NEW_CONSOLE); + P(STARTF_USESTDHANDLES); + P(HANDLE_FLAG_INHERIT); + P(INFINITE); + + P(LMEM_FIXED); + P(LMEM_ZEROINIT); + P(LPTR); + + P(FORMAT_MESSAGE_ALLOCATE_BUFFER); + P(FORMAT_MESSAGE_FROM_SYSTEM); + P(FORMAT_MESSAGE_IGNORE_INSERTS); + P(MAX_PATH); + + P(ERROR_SUCCESS); + P(ERROR_INSUFFICIENT_BUFFER); + P(ERROR_NO_MORE_ITEMS); + P(ERROR_INVALID_PARAMETER); + P(ERROR_INVALID_SID); + P(ERROR_NONE_MAPPED); + P(ERROR_BROKEN_PIPE); + + // Job object (runner kill-on-close hardening) + P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION)); + P(sizeof(IO_COUNTERS)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags)); + P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit)); + P((int)JobObjectExtendedLimitInformation); + P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + + // static assertions for the values the koffi module will hardcode + static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size"); + static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size"); + static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size"); + static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); + static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); + static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); + static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); + static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); + static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); + static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "grant mask"); + static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); + static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); + static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); + static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag"); + static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size"); + static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset"); + static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag"); + static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class"); + printf("\nstatic_asserts passed\n"); + return 0; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da6bd0e971..b55e1a2bcf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -753,6 +753,36 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/bash/pwsh-sandbox: + devDependencies: + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../../sandbox/sandbox-windows-acl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/tool-bash: dependencies: schemastery: @@ -4457,6 +4487,9 @@ importers: packages/sandbox/sandbox-local: dependencies: + '@deepseek-ai/dsh-sandbox-windows-acl': + specifier: workspace:^ + version: link:../sandbox-windows-acl node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -4502,6 +4535,22 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/sandbox/sandbox-windows-acl: + dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../sandbox-local + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/sdk/create-sdk: dependencies: '@deepseek-ai/dsh-helper': diff --git a/tsconfig.host.json b/tsconfig.host.json index 79276dfe7e..8e6f3789b7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -164,10 +164,12 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/bash-env" }, { "path": "./packages/bash/pwsh-local" }, + { "path": "./packages/bash/pwsh-sandbox" }, { "path": "./packages/bash/tool-pwsh" }, { "path": "./packages/sandbox/sandbox" }, { "path": "./packages/sandbox/sandbox-local" }, { "path": "./packages/sandbox/sandbox-policy" }, + { "path": "./packages/sandbox/sandbox-windows-acl" }, { "path": "./packages/bash/bash-sandbox" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, diff --git a/vitest.config.ts b/vitest.config.ts index cd37feb300..4aaf7258e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -47,6 +47,16 @@ const windowsCoverageExclusions = process.platform === 'win32' ] : [] +// Windows-only packages: their sources execute exclusively on win32 (koffi +// loads Win32 libraries), so the Linux coverage lane can never cover them. +// The Windows dev/CI lane exercises them through the probe/runner suites; the +// per-file 100% gate must not fail on their Linux-uncovered paths. +const windowsOnlyCoverageExclusions = process.platform !== 'win32' + ? [ + 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + ] + : [] + // Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites // self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file // far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts @@ -221,6 +231,7 @@ export default defineConfig({ 'packages/session-projection/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, + ...windowsOnlyCoverageExclusions, ...pwshCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). From 94991499c8de1be68ab14ee0eb33c6ac466c3eef Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 01:36:34 +0800 Subject: [PATCH 16/81] docs(pwsh-sandbox): real temp grant is a backend-defined choice aligned with Landlock, not deferred work --- packages/bash/pwsh-sandbox/README.i18n.yaml | 4 ++-- packages/bash/pwsh-sandbox/README.md | 4 ++-- packages/bash/pwsh-sandbox/README.zh.md | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index 7110763e72..5de8fc8e5f 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md -README.md: 5eaa513b7802fe1b4411a5c0bafabafd232d4da7 -README.zh.md: 4924feed26cb7bc50d4863a063d9a4b0bf26d954 +README.md: 222c61176b71042bfcb539a839507a425a1fb9e5 +README.zh.md: 02326bc36c9a7e1fe41817b9ab782e31a6d0b9b9 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index 5eaa513b78..222c61176b 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -17,8 +17,8 @@ The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. -## Known Limitations and Deferred Work +## Known Limitations - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. -- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`), mirroring Landlock's `/tmp` grant — a per-run private temp would need an env-block rewrite in the runner and is deferred. +- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. - **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index 4924feed26..02326bc36c 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 +沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command ` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。 执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。 @@ -17,8 +17,8 @@ 模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 -## 已知限制与后续工作 +## 已知限制 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 -- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`),与 Landlock 授予 `/tmp` 同语义——按运行创建私有临时目录需要 runner 改写环境块,留待后续。 +- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 - **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 From 757476981890662849c0f703c855dc75ac0d5102 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:11:15 +0800 Subject: [PATCH 17/81] =?UTF-8?q?fix(bundle):=20drop=20fs-local=20from=20t?= =?UTF-8?q?he=20Windows=20layer=20=E2=80=94=20duplicate=20ctx.fs=20registr?= =?UTF-8?q?ation=20failed=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows platform layer re-enables the base fs-sandbox row (removing its disable), but still inserted dsh-fs-local: both extend FileSystem and provide ctx.fs, so every shipped win32 profile failed at load. Delete the insert; fs-sandbox stays the single fs provider exactly as on POSIX. Sync the roster specs, the base/reference README pairs, the sandbox core doc (read-only grants no sink on Windows), the windows-shell JSDoc, and re-record the i18n pairings. --- apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/windows-shell.ts | 6 ++-- apps/cli/tests/windows-shell.spec.ts | 34 +++++++++++-------- docs/core-data-structures/sandbox.i18n.yaml | 4 +-- docs/core-data-structures/sandbox.md | 2 +- docs/core-data-structures/sandbox.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 +-- packages/bundle/base/README.md | 4 +-- packages/bundle/base/README.zh.md | 4 +-- packages/bundle/base/tests/base.spec.ts | 2 +- packages/bundle/base/windows.cordis.patch.yml | 7 ++-- 13 files changed, 41 insertions(+), 36 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 29cdfac304..22ed72dfb9 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 07b162517b90215a6552810124f15c435144f7d4 -README.zh.md: 80030707474e59e76583ed7edc5ddce56334cc03 +README.md: 8264e2e9f57a6687a1cddae15012324790b555ad +README.zh.md: 84bd37e43a982a307a2df8045f7f3703e5b9a8b4 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 07b162517b..8264e2e9f5 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -51,7 +51,7 @@ Process shutdown gives the plugin tree up to five seconds to dispose. The first All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup. -New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. On win32 hosts booting a shipped profile, the Windows platform layer removes the permission, sandbox, and approval rows entirely: the pwsh shell and the fs tools run unconfined with no workspace boundary (Windows has no OS sandbox runner — landlock/bwrap/seatbelt are POSIX-only — so the shipped posture is honest danger-full-access rather than a boundary the shell could bypass), and `DSH_PERMISSION_MODE` and stored permission settings have no effect there. +New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. On win32 hosts booting a shipped profile, the same permission surface runs over the Windows ACL restricted-token runner: the pwsh executor and the fs tools enforce the workspace boundary through `@deepseek-ai/dsh-sandbox-windows-acl` exactly as on POSIX (the Windows `workspace-write` grant is the workspace plus the real temp directory; `read-only` grants nothing). `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 8003070747..84bd37e43a 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -51,7 +51,7 @@ dsh web --dump-config 所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。 -新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。在 win32 主机启动交付 profile 时,Windows 平台层会整体移除 permission、sandbox 与 approval 行:pwsh shell 与 fs 工具不受限运行,不存在 workspace 边界(Windows 上没有 OS 级 sandbox runner——landlock/bwrap/seatbelt 均为 POSIX 专属——因此交付姿态是诚实的 danger-full-access,而不是一个 shell 可以绕过的边界),`DSH_PERMISSION_MODE` 与存储的权限设置在彼处也不生效。 +新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。在 win32 主机启动交付 profile 时,同一权限面运行在 Windows ACL 受限令牌 runner 之上:pwsh 执行器与 fs 工具通过 `@deepseek-ai/dsh-sandbox-windows-acl` 执行与 POSIX 完全一致的 workspace 边界(Windows 的 `workspace-write` 授权是工作区加真实 temp 目录;`read-only` 不授予任何写入)。 `DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index 425699ef4f..6bdca7b741 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -1,8 +1,8 @@ /** * The Windows shell platform layer: on win32 hosts the shipped profile - * compositions swap the POSIX-only bash stack for the PowerShell stack - * (`@deepseek-ai/dsh-pwsh-local` + `@deepseek-ai/dsh-tool-pwsh`), matching - * the Windows-pwsh-default roadmap. The layer is the base bundle's + * compositions swap the POSIX-only bash stack for the sandbox-confined + * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + + * `@deepseek-ai/dsh-tool-pwsh`), matching the Windows-pwsh-default roadmap. The layer is the base bundle's * `windows.cordis.patch.yml`, injected by the launcher between the bundle * layers and the user layers so a user patch can still override it — the * only override channel is composition config, like every other roster diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 80ba40cc34..15f5e0521b 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -14,8 +14,8 @@ import { const WINDOWS_PATCH = `- id: bash-sandbox disabled: true - insert: - - id: pwsh-local - name: '@deepseek-ai/dsh-pwsh-local' + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' ` /** One fake bundle layer rooted in a temp directory. */ @@ -48,7 +48,7 @@ describe('resolveWindowsShellLayer', () => { expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) expect(layer?.patches).toEqual([ { id: 'bash-sandbox', disabled: true }, - { insert: [{ id: 'pwsh-local', name: '@deepseek-ai/dsh-pwsh-local' }] }, + { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] }, ]) }) @@ -73,7 +73,7 @@ describe('the shipped Windows composition (real bundle layers)', () => { // suite composes the shipped patch files, not test fixtures. const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) - it('composes the win32 danger-full-access roster through the real patch layers', () => { + it('composes the win32 confined roster through the real patch layers', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) const profile = loadProfile('dsh', 'web', anchor, home) @@ -85,19 +85,24 @@ describe('the shipped Windows composition (real bundle layers)', () => { message => warnings.push(message), ) const byId = new Map(rows.map(row => [row.id, row])) - for (const id of ['bash-sandbox', 'tool-bash', 'permission', 'ui-permission', - 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + // Only the POSIX bash stack leaves the roster: the permission surface + // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled + // exactly as on POSIX — the confined pwsh executor is what changes. + for (const id of ['bash-sandbox', 'tool-bash']) { expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) } - for (const id of ['pwsh-local', 'tool-pwsh', 'fs-local']) { + for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { + expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true) + } + for (const id of ['pwsh-sandbox', 'tool-pwsh']) { expect(byId.has(id), `inserted row ${id}`).toBe(true) } - // The web-app layer provides ui-permission, so the full web profile - // composes without any no-match warning. + // The patch touches only base-owned rows plus inserts, so the full web + // profile composes without any no-match warning. expect(warnings).toEqual([]) }) - it('leaves POSIX untouched and base-only profiles warned but harmless', () => { + it('leaves POSIX untouched and base-only profiles compose without warnings', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) const profile = loadProfile('dsh', 'web', anchor, home) @@ -106,10 +111,11 @@ describe('the shipped Windows composition (real bundle layers)', () => { const posixById = new Map(posixRows.map(row => [row.id, row])) expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) expect(posixById.has('pwsh-local')).toBe(false) + expect(posixById.has('pwsh-sandbox')).toBe(false) - // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): - // ui-permission has no row to patch, so the shipped layer warns once per - // composition — never fails — exactly as its header comment documents. + // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the + // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its + // inserts, so the composition produces no no-match warning. initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) const baseOnly = loadProfile('dsh', 'base-only', anchor, home) const baseWarnings: string[] = [] @@ -119,6 +125,6 @@ describe('the shipped Windows composition (real bundle layers)', () => { [...baseOnly.layers.map(layer => layer.patches), win32!.patches], message => baseWarnings.push(message), ) - expect(baseWarnings.some(message => message.includes('ui-permission'))).toBe(true) + expect(baseWarnings).toEqual([]) }) }) diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index 34691ad25b..be2c311fd1 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md -sandbox.md: 9e5feafe046f18dad49aeaf281793f0b8e03c240 -sandbox.zh.md: a1314d1b78eb0d46ea4c8ca5aa330ee83bf89132 +sandbox.md: af2b9043c52f35b7f38b368de1420d0e2856a963 +sandbox.zh.md: 5638e76769639525b1884756805d0e0cef1e870e diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 9e5feafe04..af2b9043c5 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -8,7 +8,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. +`SandboxMode` governs filesystem effects only. `read-only` denies every write — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants nothing; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv /** diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index a1314d1b78..5638e76769 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -8,7 +8,7 @@ ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入(必需的 `/dev/null` 接收器除外);`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何写入;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index cc58dd3f8f..19f2c66290 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 9e2a6305fff592eb63f927dfcc4f32437edeb1cc -README.zh.md: 6159c23047bba4ced3345125901cc0c3ff7cd960 +README.md: de89e8c5d1fcf78fcd47ebdb98c1d1fc3bb0bd5f +README.zh.md: 45ab3d9fa3c750984d903475b3c9e027e9e95d1c diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 9e2a6305ff..de89e8c5d1 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the universal patch through the `dsh.bundle.patch` manifest field, and the launcher reads the Windows platform layer below from code on win32 hosts. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only sandboxed stacks — the bash executor/tool, the permission switcher (dsh-permission requires a confining executor), the sandbox/fs-policy stack, and the approval service — and inserts the PowerShell executor and tool (`@deepseek-ai/dsh-pwsh-local`, `@deepseek-ai/dsh-tool-pwsh`) plus the unconfined `dsh-fs-local`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the shipped posture is honest danger-full-access rather than a boundary only the fs tools pretend to enforce; nothing in the roster asks for approval, so the approval service is absent. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the bash stack restores it through its profile or home `cordis.patch.yml` (disable `pwsh-local`/`tool-pwsh` and re-enable `bash-sandbox`/`tool-bash` — both executors register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. +Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **Windows has no sandbox and no approval** — no OS runner exists on win32 (landlock/bwrap/seatbelt are POSIX-only), so the Windows platform layer removes the whole sandbox stack (`sandbox`/`sandbox-policy`/`fs-sandbox` disabled, `dsh-fs-local` provides `ctx.fs`), the permission switcher leaves the roster, and the approval service is disabled — nothing on Windows asks for approval, so the model is never told approval exists. Everything degrades to danger-full-access: the shell is unconfined and the fs tools make no confinement claims. +- **The Windows temp grant is the real temp directory** — `workspace-write` confines writes to the workspace plus the host temp area (the same backend-defined choice the Landlock rung makes); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 6159c23047..45ab3d9fa3 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析通用 patch,启动器在 win32 主机上通过代码读取下面的 Windows 平台层。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的受限栈——bash 执行器/工具、权限切换器(dsh-permission 要求有限权能力的执行器)、sandbox/fs 策略栈与 approval 服务——并插入 PowerShell 执行器与工具(`@deepseek-ai/dsh-pwsh-local`、`@deepseek-ai/dsh-tool-pwsh`)以及不限权的 `dsh-fs-local`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此交付姿态是诚实的 danger-full-access,而不是一个只有 fs 工具假装执行的边界;清单里没有任何动作需要审批,因此 approval 服务缺席。启动器在 win32 主机上把它应用于 bundle 层与用户层之间;偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 恢复 bash 栈(禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 +启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 上没有沙箱、没有 approval**:win32 上不存在 OS 级 runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此 Windows 平台层移除整个 sandbox 栈——`sandbox`/`sandbox-policy`/`fs-sandbox` 被禁用,由 `dsh-fs-local` 提供 `ctx.fs`——权限切换器离开清单,approval 服务也被禁用:Windows 上没有任何动作需要审批,模型也不会被告知审批存在。一切退化为 danger-full-access:shell 不限权,fs 工具也不做任何限权声明。 +- **Windows 的临时目录授权是真实 temp 目录**——`workspace-write` 把写入限制在工作区与宿主 temp 区域(与 Landlock 档位相同的后端定义选择);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index ab2dc874ce..2da84a0931 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -52,7 +52,7 @@ describe('dsh-base bundle', () => { const inserted = parsed .flatMap(patch => patch.insert ?? []) .map(row => row.id) - expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh', 'fs-local']) + expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh']) // The patch no longer touches the permission/approval surface at all. expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml index 66ed54112f..6db6a57098 100644 --- a/packages/bundle/base/windows.cordis.patch.yml +++ b/packages/bundle/base/windows.cordis.patch.yml @@ -5,7 +5,9 @@ # stack is the SANDBOXED PowerShell executor plus the full permission # surface: sandbox/sandbox-policy enforce the file-effect policy, the # permission switcher and the approval service run exactly as on POSIX, and -# fs-sandbox fences the in-process filesystem view. Only the POSIX bash +# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting +# dsh-fs-local alongside it would double-register ctx.fs and fail the load. +# Only the POSIX bash # stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. # A Windows host that prefers the unconfined local pwsh executor or full # access overrides these rows through its profile or home cordis.patch.yml. @@ -27,6 +29,3 @@ - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' - - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' From bec3cba0844636543f38d8b26aa0075fb1332a4b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:25:44 +0800 Subject: [PATCH 18/81] test(scripts): split placed-image paths on both separators in project-doc-site.spec.ts The placer fixtures derive the basename with split('/'), which never splits a Windows absolute path (backslashes), so the two image-placement tests fail on win32 hosts. The production rewrite passes absolute paths through; the fixtures now accept both separators. --- scripts/project-doc-site.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 6770381526..82e4077829 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = absPath.split(/[\\/]/u).pop() ?? '' placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${absPath.split(/[\\/]/u).pop() ?? ''}`, })).toBe('![logo](./logo.svg#view)\n') }) From dcd5a5037420d754358d7662d4289298e198d02a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:26:26 +0800 Subject: [PATCH 19/81] docs(sandbox): satisfy the doc-sync gates for the Windows ACL packages JSDoc every sandbox-windows-acl export (the 138 verify-export-jsdoc violations); fix the sandbox-windows-acl README code block (undeclared workspaceRoot), bring its stale claims current (the provider wiring and kill-on-close are live), add the canonical Model Experience sections to both new packages, audited the backend's indirect Model Experience sentence, and regenerate docs/config-catalog.md. --- docs/config-catalog.md | 23 +++- packages/bash/pwsh-sandbox/README.i18n.yaml | 4 +- packages/bash/pwsh-sandbox/README.md | 14 ++- packages/bash/pwsh-sandbox/README.zh.md | 14 ++- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 15 ++- .../sandbox/sandbox-windows-acl/README.zh.md | 19 ++- .../sandbox/sandbox-windows-acl/src/acl.ts | 7 ++ .../sandbox/sandbox-windows-acl/src/ffi.ts | 110 +++++++++++++++--- .../sandbox/sandbox-windows-acl/src/index.ts | 8 ++ .../sandbox/sandbox-windows-acl/src/spawn.ts | 29 ++++- .../sandbox/sandbox-windows-acl/src/token.ts | 19 ++- .../sandbox-windows-acl/src/win32-abi.ts | 62 ++++++++++ .../verify-package-readme-model-experience.ts | 1 + 14 files changed, 292 insertions(+), 37 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9fa6e8bb2..a5f89567f5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1100,6 +1100,26 @@ export interface Config { Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) +## `@deepseek-ai/dsh-pwsh-sandbox` + +Requires: `subprocess` · `sandbox` · `sandboxPolicy` + +```ts config-catalog +/** + * Plugin config: the local executor's knobs, verbatim. The sandbox policy — + * the default mode and fallback `workspace-write` root — is NOT here: it lives + * on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves + * each calling session's mode and cwd for every enforcing capability. The + * runner choice is likewise the `ctx.sandbox` provider's config, not this + * executor's. + */ +export type Config = LocalConfig +``` + +Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) + +Source: [`packages/bash/pwsh-sandbox/src/index.ts:39`](../packages/bash/pwsh-sandbox/src/index.ts) + ## `@deepseek-ai/dsh-repeat-tool-guard` ```ts config-catalog @@ -1175,7 +1195,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:24`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:28`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -2611,6 +2631,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) +- `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/sdk-client/src/index.ts`](../packages/sdk/sdk-client/src/index.ts)) diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index 5de8fc8e5f..f8100bf8a0 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md -README.md: 222c61176b71042bfcb539a839507a425a1fb9e5 -README.zh.md: 02326bc36c9a7e1fe41817b9ab782e31a6d0b9b9 +README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2 +README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index 222c61176b..bd506d011f 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -15,9 +15,19 @@ The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process ### Confinement works, denial surfaces as command failure -The model sees the confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. +#### What the model sees -## Known Limitations +The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool. + +#### Token effect + +No model-visible text beyond the command's stderr and the tool layer's standard denial surface. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + +## Known Limitations and Deferred Work - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. - **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index 02326bc36c..e9aa380302 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -15,9 +15,19 @@ ### 隔离生效,拒绝以命令失败呈现 -模型看到受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 +#### 模型看到什么 -## 已知限制 +受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。 + +#### Token 影响 + +除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + +## 已知限制与后续工作 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 - **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index d1ed2c0d43..42b5579325 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 34a9e7160bae3af93da17cae254202bc1b555967 -README.zh.md: 0ecfd146a2ef500aa34fe7b6314926a8daf802ab +README.md: 7896764867a0266bbd1e26210a2c7cccbcd09d62 +README.zh.md: 384bc5015167dfa8e4f650c0fcd22e32a62c0ab2 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 34a9e7160b..7896764867 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), built as the preparation layer for a Windows `SandboxProvider` (`workspace-write` / `read-only` modes). Linux/macOS backends live in [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/). +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. @@ -11,6 +11,8 @@ Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED ```ts import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +const workspaceRoot = process.cwd() + const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted @@ -56,9 +58,16 @@ The koffi struct definitions assert their sizes against the probe at module load - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +## Model Experience + +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. + +#### KV Cache effect + +None directly; the denial surface belongs to the tool layer. + ## Known Limitations and Deferred Work -- **No `SandboxProvider` wiring yet** — this package is the primitives layer; the `ctx.sandbox.confine()` integration (spawn-side token application plus the `denialSignatures`/`runnerFailureRules` contract) is the next step and cannot reuse the argv-wrapping style of `dsh-sandbox-local` because the restricted token must be applied at `CreateProcess` time. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. -- **Read-side confinement, network policy, and job-object kill-on-close are out of scope** for this layer and belong to the future provider design. +- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 0ecfd146a2..384bc50151 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 Windows 端 `SandboxProvider`(`workspace-write` / `read-only` 模式)实装的准备层。Linux/macOS 后端见 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/)。 +面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 @@ -11,13 +11,15 @@ ```ts import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +const workspaceRoot = process.cwd() + const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) -await sandbox.init() // 任何 Win32 调用失败都会抛错——绝不降级为无沙盒运行 +await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // 回收所有挂起的授权;逐项报告清理失败 +sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` 本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 @@ -56,9 +58,16 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 - **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +## 模型体验 + +经 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具间接生效:它们渲染本后端的强制完整性与拒绝事实(受限 stderr 由工具层按 `denialSignatures` 分类),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 + +#### KV Cache 影响 + +无直接影响;拒绝呈现面属于工具层。 + ## 已知限制与后续工作 -- **尚未接入 `SandboxProvider`** —— 本包是原语层;`ctx.sandbox.confine()` 的集成(在 spawn 侧应用受限令牌,并补齐 `denialSignatures`/`runnerFailureRules` 契约)是下一步。该集成不能沿用 `dsh-sandbox-local` 的 argv 包装风格,因为受限令牌必须在 `CreateProcess` 时生效。 - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **读侧隔离、网络策略、job-object 关闭即杀** 超出本层范围,留给未来的 provider 设计。 +- **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 58020c4470..44020ae0c3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -35,6 +35,9 @@ function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: numbe * orphan SID on `path`, inheriting to subcontainers and objects. The directory * must be owned by the caller (owner implicit WRITE_DAC) — same precondition * as the POC. + * @param api - the binding table. + * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param sidPtr - the orphan write SID the ACE names. */ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { const newAclSlot = allocPtrSlot() @@ -63,6 +66,10 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): * descriptor allocation — only the descriptor may be LocalFree'd, and it must * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL * pointer itself corrupts the heap (verified the hard way). + * @param api - the binding table. + * @param path - the directory whose DACL loses the orphan-SID ACEs. + * @param sidPtr - the orphan write SID whose ACEs are removed. + * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { const ownerSlot = allocPtrSlot() diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index a2fefecdbb..6d778f7b11 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -14,9 +14,14 @@ import * as abi from './win32-abi.ts' /** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */ declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ export type NativePtr = bigint & { readonly [nativePtr]: true } -/** True for NULL pointers, however koffi returns them (null or 0n). */ +/** + * True for NULL pointers, however koffi returns them (null or 0n). + * @param value - a pointer as koffi may hand it back (pointer, null, or 0n). + * @returns a type guard narrowing to the NULL shapes. + */ export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined { return value === null || value === undefined || (value as bigint) === 0n } @@ -40,6 +45,7 @@ export interface ProcessInfoOutput { dwThreadId: number } +/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */ export interface Win32Bindings { // ---- process / token handles -------------------------------------------- openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr @@ -111,6 +117,7 @@ export interface Win32Bindings { const PVOID: Ptr = koffi.pointer('void') const PPVOID: Ptr = koffi.pointer(PVOID) +/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */ export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { cb: 'uint32', lpReserved: 'str16', @@ -132,6 +139,7 @@ export const STARTUPINFOW = koffi.struct('STARTUPINFOW', { hStdError: PVOID, }) +/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */ export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', { hProcess: PVOID, hThread: PVOID, @@ -146,78 +154,127 @@ if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) { throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`) } -/** Allocate one pointer-sized slot (for `T **` out-parameters). */ +/** + * Allocate one pointer-sized slot (for `T **` out-parameters). + * @returns the allocated slot pointer. + */ export function allocPtrSlot(): NativePtr { const value: unknown = koffi.alloc(PVOID, 1) return value as NativePtr } -/** Allocate one uint32 slot. */ +/** + * Allocate one uint32 slot. + * @returns the allocated slot pointer. + */ export function allocUint32(): NativePtr { const value: unknown = koffi.alloc('uint32', 1) return value as NativePtr } -/** Write a uint32 value into a slot pointer. */ +/** + * Write a uint32 value into a slot pointer. + * @param slot - the slot allocated by {@link allocUint32}. + * @param value - the uint32 to encode. + */ export function encodeUint32(slot: NativePtr, value: number): void { koffi.encode(slot, 'uint32', value) } -/** Decode the pointer stored in a pointer-sized slot (NULL becomes null). */ +/** + * Decode the pointer stored in a pointer-sized slot (NULL becomes null). + * @param slot - the pointer-sized slot holding the out-parameter value. + * @returns the decoded pointer, or null for NULL. + */ export function decodePtr(slot: NativePtr): NativePtr | null { const value: unknown = koffi.decode(slot, PVOID) if (isNullPtr(value as NativePtr | null | undefined)) return null return value as NativePtr } -/** Decode a uint32 at a slot pointer. */ +/** + * Decode a uint32 at a slot pointer. + * @param slot - the uint32 slot holding the out-parameter value. + * @returns the decoded uint32. + */ export function decodeUint32(slot: NativePtr): number { const value: unknown = koffi.decode(slot, 'uint32') return value as number } -/** Decode a UTF-16 string at a pointer. */ +/** + * Decode a UTF-16 string at a pointer. + * @param ptr - pointer to the NUL-terminated UTF-16 string. + * @returns the decoded string. + */ export function decodeStr16(ptr: NativePtr): string { const value: unknown = koffi.decode(ptr, 'str16') return value as string } -/** Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). */ +/** + * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). + * @param ptr - the koffi pointer. + * @returns the pointer's numeric address. + */ export function ptrAddress(ptr: NativePtr): bigint { return koffi.address(ptr) } -/** Allocate a raw byte block (used for SID copies and variable-length arrays). */ +/** + * Allocate a raw byte block (used for SID copies and variable-length arrays). + * @param length - the block size in bytes. + * @returns the allocated block pointer. + */ export function allocBytes(length: number): NativePtr { const value: unknown = koffi.alloc('uint8', length) return value as NativePtr } -/** Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). */ +/** + * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). + * @param buffer - the buffer holding the pointer value. + * @param offset - byte offset of the pointer inside the buffer. + * @returns the decoded pointer, or null for NULL. + */ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { const value: unknown = koffi.decode(buffer, offset, PVOID) if (isNullPtr(value as NativePtr | null | undefined)) return null return value as NativePtr } -/** Allocate a zeroed STARTUPINFOW. */ +/** + * Allocate a zeroed STARTUPINFOW. + * @returns the allocated struct pointer. + */ export function allocStartupInfo(): NativePtr { const value: unknown = koffi.alloc(STARTUPINFOW, 1) return value as NativePtr } -/** Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). */ +/** + * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized). + * @param startupInfo - the allocated STARTUPINFOW to encode into. + * @param fields - the field subset to write. + */ export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void { koffi.encode(startupInfo, STARTUPINFOW, fields) } -/** Allocate a zeroed PROCESS_INFORMATION. */ +/** + * Allocate a zeroed PROCESS_INFORMATION. + * @returns the allocated struct pointer. + */ export function allocProcessInfo(): NativePtr { const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1) return value as NativePtr } -/** Decode a PROCESS_INFORMATION after CreateProcessAsUserW. */ +/** + * Decode a PROCESS_INFORMATION after CreateProcessAsUserW. + * @param processInfo - the PROCESS_INFORMATION filled by the spawn call. + * @returns the decoded handle/id fields. + */ export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput { const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION) return value as ProcessInfoOutput @@ -276,12 +333,20 @@ function bindings(): Win32Bindings { return cached } -/** Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). */ +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ export function win32(): Promise { return Promise.resolve(bindings()) } -/** Turn a Win32 error code into readable text via FormatMessageW. */ +/** + * Turn a Win32 error code into readable text via FormatMessageW. + * @param api - the binding table. + * @param win32Code - the error code to format. + * @returns the formatted message text, or '' when formatting fails. + */ export function errorText(api: Win32Bindings, win32Code: number): string { const buffer = Buffer.alloc(1024) const length = api.formatMessageW( @@ -295,13 +360,24 @@ export function errorText(api: Win32Bindings, win32Code: number): string { /** * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately * after the failed call so GetLastError is not clobbered by other Win32 calls. + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. */ export function throwLastError(api: Win32Bindings, name: string, detail?: string): never { const win32Code = api.getLastError() throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) } -/** Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). */ +/** + * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code). + * @param api - the binding table. + * @param name - the failed API's name for the error message. + * @param win32Code - the API's returned error code. + * @param detail - optional detail overriding the formatted system message. + * @returns never — always throws. + */ export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never { throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code)) } diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 77655881ca..f597ebbc72 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -38,6 +38,7 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { Win32Error } from './errors.ts' +/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] @@ -51,6 +52,7 @@ export interface AclSandboxOptions { writeSid?: string } +/** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ export interface AclSandboxSpawnOptions { /** Program to run (resolved via PATH search when unqualified, like CreateProcess). */ command: string @@ -67,12 +69,14 @@ export interface AclSandboxSpawnOptions { stdio?: 'pipe' | 'inherit' } +/** A settled confined child: captured stdio and the exit code. */ export interface AclSandboxChildResult { stdout: Buffer stderr: Buffer exitCode: number } +/** A running confined child: its pid and a settlement promise. */ export interface AclSandboxChild { /** Child process id. */ pid: number @@ -98,7 +102,9 @@ function getTempPath(api: Win32Bindings): string { * failure. */ export class AclSandbox { + /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] + /** The orphan SID string whose ACEs form the write allowlist. */ readonly writeSid: string private readonly tempDirOption: string | null | undefined private tempDirResolved: string | null | undefined @@ -199,6 +205,8 @@ export class AclSandbox { * placed in a kill-on-close job (dies with the caller). Call dispose() only * after all children have exited — revoking grants under a live child * removes its remaining write allowance. + * @param options - the program, argv/cwd, and stdio shape. + * @returns the running child. */ spawn(options: AclSandboxSpawnOptions): AclSandboxChild { const api = this.api diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 58571257a1..d9c7c70564 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -17,6 +17,8 @@ import * as abi from './win32-abi.ts' * Quote one argument per the CommandLineToArgvW parsing rules (backslash * escaping only before quotes; a trailing backslash before the closing quote * is doubled). + * @param argument - one argv entry to quote. + * @returns the quoted entry (bare when quoting is unnecessary). */ export function quoteArg(argument: string): string { if (argument === '') return '""' @@ -37,7 +39,12 @@ export function quoteArg(argument: string): string { return quoted + '"' } -/** Build the single command line CreateProcess parses from program + argv. */ +/** + * Build the single command line CreateProcess parses from program + argv. + * @param program - the executable (argv[0]). + * @param args - the remaining argv entries. + * @returns the joined, quoted command line. + */ export function buildCommandLine(program: string, args: readonly string[]): string { return [program, ...args].map(quoteArg).join(' ') } @@ -63,6 +70,7 @@ function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): v } } +/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */ export interface SpawnedNative { pid: number process: NativePtr @@ -74,6 +82,10 @@ export interface SpawnedNative { * Create a process under the restricted token with piped stdio. The child's * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends * are returned for draining. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles. */ export function spawnSandboxed( api: Win32Bindings, @@ -133,7 +145,12 @@ export function spawnSandboxed( } } -/** Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. */ +/** + * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling. + * @param api - the binding table. + * @param handle - the pipe read end to drain (closed when done). + * @returns the complete pipe contents. + */ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise { const chunks: Buffer[] = [] for (;;) { @@ -167,6 +184,9 @@ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise< * the child has already exited, so this wait returns immediately. Calling it * earlier would block the event loop and starve the drains (the pipe-buffer * deadlock the POC comments warn about). + * @param api - the binding table. + * @param process - the child process handle (closed when done). + * @returns the child's exit code. */ export function waitForExit(api: Win32Bindings, process: NativePtr): number { const waitResult = api.waitForSingleObject(process, abi.INFINITE) @@ -197,6 +217,7 @@ function createKillOnCloseJob(api: Win32Bindings): NativePtr { return job } +/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */ export interface SpawnedInherited { pid: number process: NativePtr @@ -217,6 +238,10 @@ export interface SpawnedInherited { * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles * ("The handle is invalid", verified the hard way). The child starts * suspended so it can be assigned to a kill-on-close job before it runs. + * @param api - the binding table. + * @param token - the restricted token the child runs under. + * @param options - command, args, and working directory. + * @returns the spawned child's handles and job. */ export function spawnSandboxedInherited( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index e1c0f2dc2a..569c428551 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -16,6 +16,8 @@ import * as abi from './win32-abi.ts' * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token * handle is obtained through a real OpenProcess handle because the * GetCurrentProcess() pseudo-handle is not addressable through koffi). + * @param api - the binding table. + * @returns the opened token handle. */ export function openCurrentProcessToken(api: Win32Bindings): NativePtr { const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid) @@ -42,6 +44,9 @@ export function openCurrentProcessToken(api: Win32Bindings): NativePtr { * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and * other per-logon objects; the POC extracts it the same way. + * @param api - the binding table. + * @param token - the token whose groups are scanned. + * @returns a copied logon SID (thrown when the token carries none). */ export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { const neededSlot = allocUint32() @@ -70,7 +75,12 @@ export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr { throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`) } -/** Create one well-known SID (68-byte buffer) and assert its validity. */ +/** + * Create one well-known SID (68-byte buffer) and assert its validity. + * @param api - the binding table. + * @param type - the WELL_KNOWN_SID_TYPE to create. + * @returns the created SID pointer. + */ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE) const sizeSlot = allocUint32() @@ -91,6 +101,7 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { return buffer } +/** The well-known SIDs packed into every restricted token's restricting list. */ export interface RestrictingSidSet { world: NativePtr authUser: NativePtr @@ -105,6 +116,12 @@ export interface RestrictingSidSet { * write SID that forms the write allowlist. S-1-2-1 (console logon) is * intentionally absent: see win32-abi.ts for the verified failure modes. * FAILS CLOSED: any failure throws — never spawn unrestricted. + * @param api - the binding table. + * @param currentToken - the process token to restrict. + * @param logonSid - the copied logon session SID. + * @param writeSid - the orphan SID forming the write allowlist. + * @param known - the well-known SIDs entering the restricting list. + * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 97e5322bbf..7696605c4e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -26,101 +26,161 @@ // ---- winnt.h --------------------------------------------------------------- // TOKEN_* access rights (winnt.h lines ~3928) +/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */ export const TOKEN_ASSIGN_PRIMARY = 0x0001 +/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */ export const TOKEN_DUPLICATE = 0x0002 +/** TOKEN_QUERY: required to read token information (GetTokenInformation). */ export const TOKEN_QUERY = 0x0008 +/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */ export const TOKEN_ADJUST_DEFAULT = 0x0080 // SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446) +/** + * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with + * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number). + */ export const SE_GROUP_LOGON_ID = 0xC0000000 // Generic file access (winnt.h lines ~5893-5913): // FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES // | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE +/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */ export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL +/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ export const FILE_GENERIC_WRITE = 0x00120116 // What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as // "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +/** + * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL — the write-access mask + * the orphan-SID ACEs grant (displays as "Write" in Explorer/icacls). + */ export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 // CreateRestrictedToken flags (winnt.h lines ~4284) +/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ export const DISABLE_MAX_PRIVILEGE = 0x1 +/** LUA_TOKEN: produce a limited-user (filtered admin) token. */ export const LUA_TOKEN = 0x4 +/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */ export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) +/** WinWorldSid: S-1-1-0 (Everyone). */ export const WinWorldSid = 1 +/** WinLocalSid: S-1-2-0 (LOCAL); CreateWellKnownSid(WinLocalSid) fails with ERROR_INVALID_PARAMETER on Windows 11 build 26200. */ export const WinLocalSid = 2 +/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ export const WinInteractiveSid = 11 +/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */ export const WinAuthenticatedUserSid = 17 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) +/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ export const TokenGroups = 2 // SECURITY_INFORMATION (winnt.h line ~4293) +/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ export const DACL_SECURITY_INFORMATION = 0x00000004 // PROCESS access rights (winnt.h lines ~4364) +/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */ export const PROCESS_QUERY_INFORMATION = 0x0400 // ---- accctrl.h ------------------------------------------------------------- // SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1) +/** SE_FILE_OBJECT: the trustee path names a filesystem object. */ export const SE_FILE_OBJECT = 1 // TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0 +/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */ export const TRUSTEE_IS_UNKNOWN = 0 +/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */ export const TRUSTEE_IS_SID = 0 +/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */ export const NO_MULTIPLE_TRUSTEE = 0 // ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4) +/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */ export const GRANT_ACCESS = 1 +/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */ export const REVOKE_ACCESS = 4 // grfInheritance (accctrl.h lines ~137-142) +/** + * SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its + * subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE). + */ export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE // ---- winbase.h ------------------------------------------------------------- +/** + * STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd* + * handles, required because Node clears stdio inheritability at startup. + */ export const STARTF_USESTDHANDLES = 0x00000100 +/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */ export const HANDLE_FLAG_INHERIT = 0x1 +/** INFINITE: never-timeout wait value. */ export const INFINITE = 0xFFFFFFFF +/** MAX_PATH: legacy path length bound. */ export const MAX_PATH = 260 // winbase.h line ~410: the confined child starts suspended so the runner can // assign it to the kill-on-close job before any of its code runs. +/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */ export const CREATE_SUSPENDED = 0x4 // winbase.h lines ~497-499: GetStdHandle selectors. +/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */ export const STD_INPUT_HANDLE = -10 +/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */ export const STD_OUTPUT_HANDLE = -11 +/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */ export const STD_ERROR_HANDLE = -12 // FormatMessageW flags (winbase.h lines ~1446-1469) +/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */ export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 +/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */ export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200 // ---- error codes ----------------------------------------------------------- +/** ERROR_SUCCESS: the operation succeeded. */ export const ERROR_SUCCESS = 0 +/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */ export const ERROR_INSUFFICIENT_BUFFER = 122 +/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */ export const ERROR_BROKEN_PIPE = 109 +/** ERROR_NO_DATA: the pipe is being closed. */ export const ERROR_NO_DATA = 232 // ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last // job handle closes — the orphan-child backstop for the runner design. +/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */ export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 // JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9. +/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */ export const JobObjectExtendedLimitInformation = 9 // sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. +/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */ export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144 // LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION // (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8), // verified by abi-probe. +/** + * LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION + * (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + + * PerJobUserTimeLimit@8), verified by abi-probe. + */ export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16 // ---- ABI layout, verified by verify/abi-probe.cpp (x64) -------------------- +/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */ export const SECURITY_MAX_SID_SIZE = 68 /** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */ export const SID_AND_ATTRIBUTES_SIZE = 16 @@ -132,5 +192,7 @@ export const EXPLICIT_ACCESS_W_SIZE = 48 export const TRUSTEE_W_OFFSET = 16 /** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */ export const TRUSTEE_W_PTSTRNAME_OFFSET = 24 +/** sizeof(STARTUPINFOW), verified by abi-probe. */ export const STARTUPINFOW_SIZE = 104 +/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */ export const PROCESS_INFORMATION_SIZE = 24 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 53491b89fb..663501c763 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,6 +97,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, + 'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, 'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' }, 'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' }, From 264420127ad7ad91c68feda9ca8452ed12bf3680 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:32:54 +0800 Subject: [PATCH 20/81] =?UTF-8?q?docs(sandbox):=20record=20the=20Windows?= =?UTF-8?q?=20rung=20design=20choice=20=E2=80=94=20raw=20ACL=20restricted?= =?UTF-8?q?=20tokens=20over=20mxc=20and=20AppContainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the implemented Agent Note owning the win32 rung decision: mxc rejected (OS floor 24H2, BaseContainer tier only on 25H2+ with the OS feature enabled, and arbitrary-path reads demand wholesale host DACL writes), AppContainer rejected (no ambient read access — arbitrary-path reads unsupported), landstrip already rejected and AppContainer-shaped. Link the note from the backend README pair and restate the choice in the PR description. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 6 ++++ ...08-windows-acl-restricted-token-sandbox.md | 35 +++++++++++++++++++ ...windows-acl-restricted-token-sandbox.zh.md | 35 +++++++++++++++++++ .../sandbox-windows-acl/README.i18n.yaml | 4 +-- .../sandbox/sandbox-windows-acl/README.md | 2 ++ .../sandbox/sandbox-windows-acl/README.zh.md | 2 ++ 6 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml new file mode 100644 index 0000000000..1f9871f6d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +2026-08-08-windows-acl-restricted-token-sandbox.md: 2ade3233d51d665cc919080fc898a60e5bea7717 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 701bf2a8f84df7a5bb6666b7dd0996e56f4c3686 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md new file mode 100644 index 0000000000..2ade3233d5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer + +Status: implemented + +English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md) + +## Problem + +The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading. + +## Decision + +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. + +## Alternatives considered + +### Why not mxc (Microsoft xContainer)? + +Two disqualifiers. First, the OS floor is too new: the [mxc OS-version policy](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) sets the product floor at Windows 11 24H2 (build 26100), and the BaseContainer tier (T1, `Experimental_CreateProcessInSandbox`) exists only on 25H2+ (build 26600+) with the OS feature enabled — on every supported release at or below 25H2 the filesystem policy falls back to T3, AppContainer plus host-side DACL ACE augmentation. Second, supporting arbitrary-path reads under either tier means granting read access by writing ACLs over every path the child may read: a model that reads the whole workspace and arbitrary files would require wholesale host DACL mutation — a standing side effect and a cost a write-only restriction does not need. + +### Why not AppContainer? + +An AppContainer token carries no ambient read access: every readable path must be pre-granted through capabilities or explicit ACEs, so arbitrary-path reads — the harness's read model — are unsupported without the same wholesale grants. The restricted token needs no read grants at all: it intersects write access only. + +### Why not landstrip? + +The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem. + +## Consequences + +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. + +## Related + +The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md new file mode 100644 index 0000000000..701bf2a8f8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer + +Status: implemented + +[English](2026-08-08-windows-acl-restricted-token-sandbox.md) | 中文 + +## Problem + +[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。 + +## Decision + +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 + +## Alternatives considered + +### 为什么不选 mxc(Microsoft xContainer)? + +两个否决理由。其一,OS 版本要求太新:[mxc 的 OS 版本支持文档](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md)把产品下限设在 Windows 11 24H2(build 26100),而 BaseContainer 档(T1,`Experimental_CreateProcessInSandbox`)只在 25H2+(build 26600+)且启用 OS feature 时存在——在 25H2 及以下的所有受支持版本上,文件系统策略都会回退到 T3,即 AppContainer 加宿主侧 DACL ACE 改造。其二,在任一档下支持任意路径读都意味着要为子进程可读的每个路径写 ACL 授予读权限:模型要读整个工作区和任意文件,就需要全盘改写宿主 DACL——对只做写限制的需求而言,这是不必要的驻留副作用与代价。 + +### 为什么不选 AppContainer? + +AppContainer 令牌没有环境读访问:每个可读路径都必须预先通过 capability 或显式 ACE 授予,因此任意路径读——harness 的读模型——在不做同样的全盘授予时无法支持。受限令牌完全不需要读授予:它只对写访问做交集。 + +### 为什么不选 landstrip? + +[landstrip 评估](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。 + +## Consequences + +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 + +## Related + +[pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 42b5579325..b23dc56468 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 7896764867a0266bbd1e26210a2c7cccbcd09d62 -README.zh.md: 384bc5015167dfa8e4f650c0fcd22e32a62c0ab2 +README.md: a3b12b6298cc30f490adf7aa8d2cc1916fd48827 +README.zh.md: c3ff553dca4ca3866b6dd0d20381cecd906eba42 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 7896764867..a3b12b6298 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -6,6 +6,8 @@ Windows write-restriction sandbox backend for the [harness sandbox seam](../sand Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). + ## Usage ```ts diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 384bc50151..c3ff553dca 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -6,6 +6,8 @@ 一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 + ## 用法 ```ts From aa790346e45f31a680400022f3f59ecbab84b7a5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 02:42:47 +0800 Subject: [PATCH 21/81] docs(sandbox): explain why the token intersection needs no new identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the mechanism section to the Windows rung Agent Note: identity routes (restricted-user, AppContainer) default-deny everything from a fresh SID and pay per-path ACE writes for every read, while the WRITE_RESTRICTED token keeps the caller's SID and double-checks write-class access only — reads pass on the normal check alone. Covers DISABLE_MAX_PRIVILEGE/LUA_TOKEN as the token-side limited-user synthesis and why SidsToDisable read restriction is unused. --- .../2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- .../2026-08-08-windows-acl-restricted-token-sandbox.md | 4 ++++ .../2026-08-08-windows-acl-restricted-token-sandbox.zh.md | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 1f9871f6d7..c75002e390 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 2ade3233d51d665cc919080fc898a60e5bea7717 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 701bf2a8f84df7a5bb6666b7dd0996e56f4c3686 +2026-08-08-windows-acl-restricted-token-sandbox.md: 167f4144a3cf6a80d00261ddb09bf3db0e024d63 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: c49237072c8b831ede927b2c4704676837029397 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 2ade3233d5..167f4144a3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -12,6 +12,10 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +## How the restriction works (why no new identity) + +The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. + ## Alternatives considered ### Why not mxc (Microsoft xContainer)? diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 701bf2a8f8..c49237072c 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -12,6 +12,10 @@ Status: implemented 直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +## How the restriction works (why no new identity) + +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 + ## Alternatives considered ### 为什么不选 mxc(Microsoft xContainer)? From d8acd2b65645ff02e6c5dd229d039ca5b6ead751 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 11:46:05 +0800 Subject: [PATCH 22/81] fix(ci): pass the pwsh-less self-hosted Linux runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted Linux runners ship no pwsh, and the pwshAvailable probes used spawnSync('where.exe'), which reports a missing binary as status null instead of throwing — the suites never skipped and failed with spawn pwsh ENOENT. Probe with resolvePwshPath() status instead, the same gate the coverage exemption uses. Exempt pwsh-sandbox src from coverage on pwsh-less hosts (its remaining helpers branch and the invariant companion ride the executor suites' real pwsh runs); pwsh-ful hosts keep the full 100% bar. Cover the windows-acl probe case and the runner-entry resolution in sandbox-local on Linux (chain-seam tests plus a windowsAclRunnerEntry seam) — the package's POSIX-only suites are Linux's only chance to cover the new lines. Static gate fixes: declare dsh-pwsh-sandbox in the base bundle, register the runner files entry in constraints, knip entries for the e2e suite and where.exe, regenerate the module graph. Verified in WSL (no-pwsh Linux): pwsh-sandbox 5 pass/13 skip with the exemption active, sandbox-local coverage 100%. --- docs/module-graph.md | 10 ++++ knip.json | 13 +++- packages/bash/pwsh-sandbox/package.json | 1 - packages/bash/pwsh-sandbox/tests/acl.e2e.ts | 8 +-- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 11 ++-- packages/bundle/base/package.json | 2 +- packages/sandbox/sandbox-local/src/index.ts | 4 +- .../sandbox/sandbox-local/tests/local.spec.ts | 60 +++++++++++++++++++ .../sandbox/sandbox-windows-acl/package.json | 2 +- pnpm-lock.yaml | 7 +-- scripts/check-workspace-constraints.ts | 3 + vitest.config.ts | 22 ++++--- 12 files changed, 113 insertions(+), 30 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1a7ae5c8d2..3657523e5b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -42,6 +42,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -236,6 +237,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_sdk["packages/sdk"] pkg_helper["helper"] @@ -309,6 +311,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_typert_generator --> pkg_invariants @@ -632,6 +635,11 @@ flowchart TD pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_invariants @@ -1152,6 +1160,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | @@ -1240,6 +1249,7 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/knip.json b/knip.json index 7a3922c8fb..e6ed5506e3 100644 --- a/knip.json +++ b/knip.json @@ -7,7 +7,8 @@ "bwrap", "python3", "sandbox-exec", - "taskkill" + "taskkill", + "where.exe" ], "ignoreWorkspaces": [ "vendor/*", @@ -217,6 +218,16 @@ "tests/**/*.ts" ] }, + "packages/bash/pwsh-sandbox": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/context/time-context": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 47f51b2ac1..6f2a87fc58 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -39,7 +39,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index a5909f0544..bb6f4f25e4 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' @@ -22,12 +23,7 @@ import { SandboxPwshExecutor } from '../src/index.ts' const isWin32 = process.platform === 'win32' function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index 1c8e5e1bd0..9e4aa04546 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -14,18 +14,17 @@ import { afterAll, describe, expect, it } from 'vitest' import { Context, Service } from 'cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { SandboxPwshExecutor } from '../src/index.ts' import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts' +// The same probe pwsh-local's suites and the vitest coverage exemption use: +// spawnSync never throws on a missing binary (it reports status null), and +// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth. function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-')) diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 593d5d3f98..f7ee47e343 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,7 +58,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-repository-plugin": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 22182095d5..0b562616ea 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -112,6 +112,8 @@ export interface SandboxInternals { seatbeltExec?: string /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */ windowsAclRunnerArgs?: string[] + /** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */ + windowsAclRunnerEntry?: string /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ probeWindowsAcl?: () => boolean } @@ -368,7 +370,7 @@ export class LocalSandboxProvider extends SandboxProvider { private windowsAclRunnerInvocation(): string[] { const override = this.internals.windowsAclRunnerArgs if (override !== undefined) return override - const builtEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) + const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) if (existsSync(builtEntry)) return [process.execPath, builtEntry] const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts')) return [process.execPath, '--import', 'tsx/esm', sourceEntry] diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 5e12b37ad2..3c764a8bae 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -365,3 +365,63 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => { expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE })) }) }) + +describe('the windows-acl probe (runner invocation contract)', () => { + // The product chain reaches windows-acl only unprobed (win32's sole + // candidate), so the probe case and the runner-entry resolution are pinned + // through the chain seam, mirroring the seatbelt default-probe contract. + it('selects the rung when the injected probe passes, speaking the ACL dialect', async () => { + const probeWindowsAcl = vi.fn(() => true) + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl, + probeBwrap: () => false, + windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'], + }) + const confined = sandbox.confine(['true'], RO) + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) + expect(confined.enforcement).toBe('full') + expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) + expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + }) + + it('reads a failing probe as unusable and walks to the next rung', async () => { + const probeWindowsAcl = vi.fn(() => false) + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeWindowsAcl, probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + expect(probeWindowsAcl).toHaveBeenCalledTimes(1) + }) + + it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => { + // The default probe spawns the exact runner argv confine would use — the + // runner source through tsx on a lib-less checkout. The windows-acl + // runner cannot init off win32, so the probe reads unusable and the walk + // falls through to the injected bwrap verdict on every host. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }, 30_000) + + it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => { + // windowsAclRunnerInvocation always yields [node, ...] in product; an + // override returning [] exercises the default probe's empty-argv guard. + const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true, windowsAclRunnerArgs: [] }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv[0]).toBe('bwrap') + }) + + it('prefers the built lib/runner.js entry when the resolved file exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-')) + const builtEntry = join(dir, 'runner.js') + writeFileSync(builtEntry, '') + const { sandbox } = await setup({}, { + chain: ['windows-acl', 'bwrap'], + probeWindowsAcl: () => true, + windowsAclRunnerEntry: builtEntry, + }) + const confined = sandbox.confine(['true'], RO) + expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 3e2afd8fb3..3f4d6cc065 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -24,8 +24,8 @@ }, "files": [ "lib/index.js", - "lib/runner.js", "lib/invariant.js", + "lib/runner.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b55e1a2bcf..391c0947a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -773,9 +773,6 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-sandbox-windows-acl': - specifier: workspace:^ - version: link:../../sandbox/sandbox-windows-acl '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -973,9 +970,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode - '@deepseek-ai/dsh-pwsh-local': + '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ - version: link:../../bash/pwsh-local + version: link:../../bash/pwsh-sandbox '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f0cea80064..37ac29a1a5 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -110,6 +110,9 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], + // The argv-prefix runner entry ships beside the lib as its own bundle; + // sandbox-local resolves it through the package's ./runner export. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], '@deepseek-ai/dsh-scripts': [ 'lib/dev/tsdown-config.js', 'lib/local-plugin-loader-hooks.js', diff --git a/vitest.config.ts b/vitest.config.ts index 4aaf7258e0..dc0848ff0d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,16 +57,22 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' ] : [] -// Mirrors windowsCoverageExclusions: pwsh-local's run/start/lifecycle suites -// self-skip without a real pwsh (executor.spec.ts hasPwsh), leaving this file -// far below per-file 100% on pwsh-less hosts; the exemption keeps those hosts -// green while CI runners ship pwsh and still enforce the full bar. The probe -// runs the suites' own resolution (the dependency-free resolve.ts module), -// so the exemption is active exactly when the suites skip — a mismatched -// narrower probe could exempt the file on hosts whose suites actually run. +// Mirrors windowsCoverageExclusions: pwsh-local's and pwsh-sandbox's +// run/start/lifecycle suites self-skip without a real pwsh (executor.spec.ts +// hasPwsh, sandbox.spec.ts pwshAvailable). pwsh-local keeps the bar on its +// pwsh-independent modules and exempts only the executor file; pwsh-sandbox's +// remaining helpers branch (classifyDenial) and its invariant companion both +// ride the executor suites' real pwsh runs, so on a pwsh-less host (the +// self-hosted Linux runners ship no pwsh) every source file would sit below +// the per-file bar — exempt the whole package src there, mirroring +// windowsOnlyCoverageExclusions. The probe runs the suites' own resolution +// (resolvePwshPath), so the exemption is active exactly when the suites skip. const pwshCoverageExclusions = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 ? [] - : ['packages/bash/pwsh-local/src/index.ts'] + : [ + 'packages/bash/pwsh-local/src/index.ts', + 'packages/bash/pwsh-sandbox/src/**/*.ts', + ] const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', From ae7d16815da044f835662b727dcfda6c99c2aa6c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 11:51:53 +0800 Subject: [PATCH 23/81] chore: retrigger CI (no workflow run was created for the previous push) From e660ec8cf586e3d5300dbf08cf6e1ca3411ccb20 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 12:20:49 +0800 Subject: [PATCH 24/81] fix(sandbox): pack the windows-acl rung in the publish-path rehearsal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sandbox-local now depends on @deepseek-ai/dsh-sandbox-windows-acl, so the packed-install e2e must carry its tarball in the workspace closure — npm cannot resolve the private package from the registry. koffi and the shared peers (dsh-invariants, cordis) already resolve from the registry or the existing closure. --- packages/sandbox/sandbox-local/tests/packed-install.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index a032e1add7..408a4061a9 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -23,6 +23,10 @@ const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) /** The closure the consumer needs: the package and its transitive `@deepseek-ai` peers; the launcher family arrives from the registry. */ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-local', + // sandbox-local's win32 chain rung is a runtime dependency: a packed + // consumer resolves it like any other @deepseek-ai peer (koffi arrives + // from the registry). + 'packages/sandbox/sandbox-windows-acl', 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', From 83f288993fea768632f5b3aa735ebf9ff00d55f2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 12:35:27 +0800 Subject: [PATCH 25/81] fix(sandbox): mark the pwsh-sandbox executor's deliberate twin mirror for the clone gate The duplication gate flags the call-for-call mirror of bash-sandbox's executor (the pwsh-tool-and-executor decision): wrap the class body in jscpd ignore markers, the same convention the invariant companions use. --- packages/bash/pwsh-sandbox/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts index c88719f889..38e44a431a 100644 --- a/packages/bash/pwsh-sandbox/src/index.ts +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -45,6 +45,7 @@ export type Config = LocalConfig * calls fall back to deployment policy. `result.sandbox` reports the mode and * enforcement actually used. */ +/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */ export class SandboxPwshExecutor extends PwshLocalExecutor { static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] @@ -180,5 +181,6 @@ export class SandboxPwshExecutor extends PwshLocalExecutor { return this.ctx.sandbox.confine(this.argv(spec), policy) } } +/* jscpd:ignore-end */ export default SandboxPwshExecutor From 2ecfe383c795752c37ea6c728806e81c6508ceb3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 13:44:05 +0800 Subject: [PATCH 26/81] fix(cli): resolve the pwsh-sandbox layer row from the cold-start module closure The launcher's healProfilesModuleFallback BFS-links the apps/cli dependency closure into the profile's node_modules (the pwsh-local precedent): without dsh-pwsh-sandbox in apps/cli dependencies, a fresh Windows host cannot resolve the inserted row. Assert the closure reaches both inserted packages in the real-layers composition; rewrap the windows-shell layer comment. --- apps/cli/package.json | 1 + apps/cli/src/windows-shell.ts | 2 +- apps/cli/tests/windows-shell.spec.ts | 9 ++++++++- pnpm-lock.yaml | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 5e4f189517..4673e64bc4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-pty": "workspace:^", "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index 6bdca7b741..0058d89d38 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -2,7 +2,7 @@ * The Windows shell platform layer: on win32 hosts the shipped profile * compositions swap the POSIX-only bash stack for the sandbox-confined * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + - * `@deepseek-ai/dsh-tool-pwsh`), matching the Windows-pwsh-default roadmap. The layer is the base bundle's + * `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's * `windows.cordis.patch.yml`, injected by the launcher between the bundle * layers and the user layers so a user patch can still override it — the * only override channel is composition config, like every other roster diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 15f5e0521b..cc77619f8c 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs' +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -97,6 +97,13 @@ describe('the shipped Windows composition (real bundle layers)', () => { for (const id of ['pwsh-sandbox', 'tool-pwsh']) { expect(byId.has(id), `inserted row ${id}`).toBe(true) } + // The launcher's cold-start module fallback BFS-links the apps/cli + // dependency closure into the profile's node_modules (the pwsh-local + // precedent), so every inserted bare plugin must resolve from there. + const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record } + for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) { + expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined() + } // The patch touches only base-owned rows plus inserts, so the full web // profile composes without any no-match warning. expect(warnings).toEqual([]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b390c886a0..b06386f144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local + '@deepseek-ai/dsh-pwsh-sandbox': + specifier: workspace:^ + version: link:../../packages/bash/pwsh-sandbox '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference From fa591b5ebd73e32878e924c717fcb238746c54c6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 13:44:10 +0800 Subject: [PATCH 27/81] docs(sandbox): record the DACL churn limitation and the win32 snapshot substitution Known Limitations gains the per-command DACL mutation entry (inheritance is lazy, not a per-file walk; per-session grant reuse is deferred work); the design note gains a Testing section stating why the win32-only roster flip cannot ride macOS/Linux snapshot fixtures and naming the substitute evidence. --- .../2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- .../2026-08-08-windows-acl-restricted-token-sandbox.md | 4 ++++ .../2026-08-08-windows-acl-restricted-token-sandbox.zh.md | 4 ++++ packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 1 + packages/sandbox/sandbox-windows-acl/README.zh.md | 1 + 6 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index c75002e390..9bb02e92b6 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 167f4144a3cf6a80d00261ddb09bf3db0e024d63 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: c49237072c8b831ede927b2c4704676837029397 +2026-08-08-windows-acl-restricted-token-sandbox.md: db793ab31a853a6627b2e30f4dd3f71542e3836d +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a3d69a2dd94de2fa4ef83aa16ee297c1939b767d diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 167f4144a3..db793ab31a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -34,6 +34,10 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. +## Testing + +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. + ## Related The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index c49237072c..a3d69a2dd9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -34,6 +34,10 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 +## Testing + +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。 + ## Related [pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。 diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index b23dc56468..ad25c7e95f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: a3b12b6298cc30f490adf7aa8d2cc1916fd48827 -README.zh.md: c3ff553dca4ca3866b6dd0d20381cecd906eba42 +README.md: d4f99c44f0542e9ed6e7d4605730ad0f0311a22c +README.zh.md: 9b51ccf7b29aefc3dea9a1c97bad75c2f41f5f98 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index a3b12b6298..d4f99c44f0 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -72,4 +72,5 @@ None directly; the denial surface belongs to the tool layer. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Each confined command mutates two directory DACLs** — a grant on entry and a revoke on exit, on the workspace root and the temp root: a handful of Win32 calls per command (inheritance is evaluated lazily per access, not a per-file walk). The runner pays this per command; reusing one grant per session is deferred work if the churn ever matters. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index c3ff553dca..9b51ccf7b2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -72,4 +72,5 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **每次受限命令都会改动两个目录的 DACL** —— 进入时授权、退出时撤销,分别作用在工作区根与临时根:每次命令若干次 Win32 调用(继承按访问惰性求值,不是逐文件遍历)。runner 按命令付这笔开销;按会话复用一次授权留作后续工作,待开销真的成为问题再实现。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 From 91d3ed6c5acf7e7af8f3c300a5ca1f5a73c64526 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 14:32:01 +0800 Subject: [PATCH 28/81] =?UTF-8?q?fix(sandbox):=20address=20the=20ACL=20bac?= =?UTF-8?q?kend=20review=20findings=20=E2=80=94=20quoting,=20DACL=20merge+?= =?UTF-8?q?lock,=20failure=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quoteArg doubles end-of-string backslashes (real CommandLineToArgvW round-trip test; first-token exemption noted); grantWrite merges into the current DACL instead of replacing it, and both grant/revoke hold a LockFileEx per-path lock under GetTempPathW/dsh-acl-locks (koffi crashes on NULL lpOverlapped — a zeroed OVERLAPPED is used); GRANT_MASK gains DELETE|FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER) though the win32 26200 second check only constrains the WRITE bit; failure paths close all handles (CreateProcessAsUserW pipe set, ResumeThread thread/process/job) with stub-api tests; getTempPathW refuses undersized buffers; drainPipe backs off; runner.spec pwshAvailable uses the resolvePwshPath probe; NTSTATUS exit codes mirror bit-exact (verified end-to-end); WinLocalSid JSDoc re-attributed to WinLocalLogonSid. All gates green: 42 passed/2 skipped, typecheck, oxlint, 0 clones, constraints, knip. --- .../sandbox/sandbox-windows-acl/package.json | 1 + .../sandbox/sandbox-windows-acl/src/acl.ts | 206 +++++++++++---- .../sandbox/sandbox-windows-acl/src/ffi.ts | 56 +++++ .../sandbox/sandbox-windows-acl/src/index.ts | 9 +- .../sandbox/sandbox-windows-acl/src/runner.ts | 9 + .../sandbox/sandbox-windows-acl/src/spawn.ts | 46 +++- .../sandbox-windows-acl/src/win32-abi.ts | 64 ++++- .../sandbox-windows-acl/tests/acl.spec.ts | 234 ++++++++++++++++++ .../tests/failure-paths.spec.ts | 112 +++++++++ .../sandbox-windows-acl/tests/quote.spec.ts | 88 +++++++ .../sandbox-windows-acl/tests/runner.spec.ts | 37 ++- .../sandbox-windows-acl/verify/abi-probe.cpp | 23 +- pnpm-lock.yaml | 3 + 13 files changed, 805 insertions(+), 83 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 3f4d6cc065..2f13b71296 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 44020ae0c3..7700474d5f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -4,10 +4,19 @@ * the failure handling the POC lacks). Every API call is checked and every * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. + * + * Concurrency: grants are read-merge-write against the directory's CURRENT + * DACL, and the whole get-merge-set sequence runs under a per-path exclusive + * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances + * cannot clobber each other's ACEs. * @module @deepseek-ai/dsh-sandbox-windows-acl/acl */ -import { allocPtrSlot, decodePtr, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import { createHash } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { allocOverlapped, allocPtrSlot, decodePtr, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' @@ -18,7 +27,7 @@ import * as abi from './win32-abi.ts' * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which * removes every ACE for the trustee. */ -function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { +export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) entry.writeUInt32LE(permissions, 0) // grfAccessPermissions entry.writeUInt32LE(mode, 4) // grfAccessMode @@ -31,47 +40,82 @@ function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: numbe } /** - * Grant `FILE_GENERIC_WRITE & ~READ_CONTROL` (displays as "Write") to the - * orphan SID on `path`, inheriting to subcontainers and objects. The directory - * must be owned by the caller (owner implicit WRITE_DAC) — same precondition - * as the POC. + * One lock file per protected path: `\dsh-acl-locks\.lock`. The lock root derives from + * GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing + * maps Windows's case-insensitive path spellings onto one lock. * @param api - the binding table. - * @param path - the directory whose DACL gains the grant (the workspace or temp root). - * @param sidPtr - the orphan write SID the ACE names. + * @param path - the protected directory (absolute). + * @returns the lock file path for that directory. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { - const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), null, newAclSlot) - if (mergeResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', mergeResult, path) - const newAcl = decodePtr(newAclSlot) - if (newAcl === null) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `null ACL for ${path}`) - - const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, - null, null, newAcl, null, - ) - // Free the LocalAlloc'd ACL before any throw; capture both outcomes first. - const freed = api.localFree(newAcl) - if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, path) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path})`) +export function lockFilePath(api: Win32Bindings, path: string): string { + const digest = createHash('sha256').update(path.toLowerCase()).digest('hex').slice(0, 16) + return join(getTempPath(api), 'dsh-acl-locks', `${digest}.lock`) } /** - * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved). Returns whether an ACE removal was - * attempted (false when the directory carries no DACL at all). - * - * Allocation contract (the POC's RevokeAccess, minus its missing checks): - * GetNamedSecurityInfoW returns the DACL pointer INSIDE the security - * descriptor allocation — only the descriptor may be LocalFree'd, and it must - * not be freed before SetEntriesInAclW has consumed the ACL. Freeing the ACL - * pointer itself corrupts the heap (verified the hard way). + * Run `action` holding the per-path exclusive lock: CreateFileW + * (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file + * could be removed and recreated under the holder, letting two processes + * hold "the same" lock), then a one-byte LockFileEx + * (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the + * synchronous handle — see allocOverlapped for why not NULL), then + * UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures + * throw like every other Win32 call in this package; an `action` failure + * still unlocks (best-effort) and rethrows the original error. * @param api - the binding table. - * @param path - the directory whose DACL loses the orphan-SID ACEs. - * @param sidPtr - the orphan write SID whose ACEs are removed. - * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). + * @param path - the protected directory (absolute). + * @param action - the get-merge-set sequence to serialize. + * @returns the action's result. */ -export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { +export function withPathLock(api: Win32Bindings, path: string, action: () => T): T { + const lockPath = lockFilePath(api, path) + mkdirSync(dirname(lockPath), { recursive: true }) + const handle = api.createFileW( + lockPath, + abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, + null, abi.OPEN_ALWAYS, 0, null, + ) + if (isInvalidHandle(handle)) throwLastError(api, 'CreateFileW', lockPath) + const overlapped = allocOverlapped() // stays zeroed: offset 0, hEvent NULL + if (api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped) === 0) { + const win32Code = api.getLastError() + api.closeHandle(handle) // best-effort on the lock-failure path + throwWin32(api, 'LockFileEx', win32Code, lockPath) + } + + let result: T + try { + result = action() + } catch (error) { + // Best-effort release on the action-failure path: cleanup failures must + // not mask the action's error. + api.unlockFileEx(handle, 0, 1, 0, overlapped) + api.closeHandle(handle) + throw error + } + if (api.unlockFileEx(handle, 0, 1, 0, overlapped) === 0) { + const win32Code = api.getLastError() + api.closeHandle(handle) // best-effort on the unlock-failure path + throwWin32(api, 'UnlockFileEx', win32Code, lockPath) + } + if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', `lock file ${lockPath}`) + return result +} + +/** + * Read the directory's current explicit DACL via GetNamedSecurityInfoW. + * Allocation contract (the POC's RevokeAccess, minus its missing checks): the + * returned ACL pointer sits INSIDE the security descriptor allocation — only + * the descriptor may be LocalFree'd, and it must not be freed before + * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself + * corrupts the heap (verified the hard way). + * @param api - the binding table. + * @param path - the directory whose DACL is read. + * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor. + */ +function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } { const ownerSlot = allocPtrSlot() const groupSlot = allocPtrSlot() const daclSlot = allocPtrSlot() @@ -82,27 +126,39 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, ) if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) - const oldAcl = decodePtr(daclSlot) - const descriptor = decodePtr(descriptorSlot) - - if (oldAcl === null) { - if (descriptor !== null) { - const freed = api.localFree(descriptor) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) - } - return false - } + return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) } +} +/** + * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` + * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), + * free the descriptor before applying the merged ACL, apply it, then free the + * merged ACL — checking every call and reporting with the caller's label. + * @param api - the binding table. + * @param path - the directory the DACL edit applies to. + * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). + * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}). + * @param descriptor - the descriptor allocation owning `oldAcl`. + * @param label - the caller's name for error details. + */ +function mergeAndApply( + api: Win32Bindings, + path: string, + entry: Buffer, + oldAcl: NativePtr | null, + descriptor: NativePtr | null, + label: string, +): void { const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, newAclSlot) + const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too - throwWin32(api, 'SetEntriesInAclW', mergeResult, `revokeWrite(${path})`) + throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) } const newAcl = decodePtr(newAclSlot) if (newAcl === null) { if (descriptor !== null) api.localFree(descriptor) - throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `revokeWrite(${path}): null new ACL`) + throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`) } // The descriptor block (oldAcl included) is dead after the merge — free it @@ -113,8 +169,52 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) null, null, newAcl, null, ) const freedNew = api.localFree(newAcl) - if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `revokeWrite(${path})`) - if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) - if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) new ACL`) - return true + if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) + if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) + if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) +} + +/** + * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID + * on `path`, inheriting to subcontainers and objects. Read-merge-write: the + * new ACE merges into the directory's CURRENT explicit DACL (same shape as + * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the + * per-path lock. The directory must be owned by the caller (owner implicit + * WRITE_DAC) — same precondition as the POC. + * @param api - the binding table. + * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param sidPtr - the orphan write SID the ACE names. + */ +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { + withPathLock(api, path, () => { + const { oldAcl, descriptor } = readCurrentDacl(api, path) + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') + }) +} + +/** + * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * merge — other entries are preserved). Returns whether an ACE removal was + * attempted (false when the directory carries no DACL at all). + * + * Runs under the per-path lock (the whole get-merge-set sequence); the + * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. + * @param api - the binding table. + * @param path - the directory whose DACL loses the orphan-SID ACEs. + * @param sidPtr - the orphan write SID whose ACEs are removed. + * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). + */ +export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { + return withPathLock(api, path, () => { + const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl === null) { + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`) + } + return false + } + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite') + return true + }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 6d778f7b11..1d9665a8fc 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -26,6 +26,17 @@ export function isNullPtr(value: NativePtr | null | undefined): value is null | return value === null || value === undefined || (value as bigint) === 0n } +/** + * True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which + * koffi hands back as the unsigned 64-bit all-ones pointer). + * @param handle - the handle CreateFileW returned. + * @returns whether the handle signals failure. + */ +export function isInvalidHandle(handle: NativePtr | null | undefined): boolean { + if (isNullPtr(handle)) return true + return (handle as bigint) === 0xFFFFFFFFFFFFFFFFn || (handle as bigint) === -1n +} + type Ptr = ReturnType /** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */ @@ -86,6 +97,12 @@ export interface Win32Bindings { ): number // ---- environment / io ---------------------------------------------------- getTempPathW(length: number, buffer: Buffer): number + createFileW( + fileName: string, desiredAccess: number, shareMode: number, attributes: null, + creationDisposition: number, flagsAndAttributes: number, templateFile: null, + ): NativePtr + lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number + unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number setHandleInformation(handle: NativePtr, mask: number, flags: number): number createProcessAsUserW( @@ -231,6 +248,18 @@ export function allocBytes(length: number): NativePtr { return value as NativePtr } +/** + * Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8, + * Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this + * instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a + * zeroed OVERLAPPED on a synchronous file handle is the documented equivalent + * (the byte range locks from offset 0, hEvent stays NULL). + * @returns the zeroed block pointer. + */ +export function allocOverlapped(): NativePtr { + return allocBytes(32) +} + /** * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries). * @param buffer - the buffer holding the pointer value. @@ -313,6 +342,14 @@ function bindings(): Win32Bindings { setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]), getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]), + // fileapi.h line ~64: HANDLE CreateFileW(LPCWSTR, DWORD, DWORD, + // LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE). + createFileW: bind(kernel32, 'CreateFileW', PVOID, ['str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID]), + // fileapi.h lines ~177/~185: BOOL LockFileEx(HANDLE, DWORD, DWORD, DWORD, + // DWORD, LPOVERLAPPED); BOOL UnlockFileEx(HANDLE, DWORD, DWORD, DWORD, + // LPOVERLAPPED). lpOverlapped is NULL for synchronous locking. + lockFileEx: bind(kernel32, 'LockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID]), + unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']), setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']), createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [ @@ -357,6 +394,25 @@ export function errorText(api: Win32Bindings, win32Code: number): string { return buffer.subarray(0, length * 2).toString('utf16le').trim() } +/** + * Read the process temp directory via GetTempPathW (fileapi.h line ~188). + * Defensive against an overlong system temp path: GetTempPathW reports the + * REQUIRED length (including NUL) without writing the buffer when it is too + * small, so a reported length beyond the buffer's capacity means the buffer + * was never filled and must not be decoded. + * @param api - the binding table. + * @returns the NUL-terminated temp path decoded as a string. + */ +export function getTempPath(api: Win32Bindings): string { + const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) + const length = api.getTempPathW(buffer.length / 2, buffer) + if (length === 0) throwLastError(api, 'GetTempPathW') + if (length > buffer.length / 2) { + throw new Win32Error('GetTempPathW', abi.ERROR_INSUFFICIENT_BUFFER, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`) + } + return buffer.subarray(0, length * 2).toString('utf16le') +} + /** * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately * after the failed call so GetLastError is not clobbered by other Win32 calls. diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index f597ebbc72..e312a40e09 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -29,7 +29,7 @@ import { resolve } from 'node:path' import { grantWrite, revokeWrite } from './acl.ts' import { Win32Error } from './errors.ts' -import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' +import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' @@ -88,13 +88,6 @@ function randomWriteSid(): string { return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` } -function getTempPath(api: Win32Bindings): string { - const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2) - const length = api.getTempPathW(buffer.length / 2, buffer) - if (length === 0) throwLastError(api, 'GetTempPathW') - return buffer.subarray(0, length * 2).toString('utf16le') -} - /** * One write-restricted sandbox instance: token + orphan-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes whatever was granted diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 02fd416f7f..578a615f84 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -124,6 +124,15 @@ async function main(): Promise { main().then( (exitCode) => { + // Exit-code mirroring is full-width on Windows, verified empirically on + // this machine (Windows 11 build 26200, Node 24): a child that exits + // with the NTSTATUS 0xC0000005 (STATUS_ACCESS_VIOLATION) is read back + // by GetExitCodeProcess as the uint32 3221225477, and after + // process.exitCode = 3221225477 the parent observes exactly + // 3221225477 (spawnSync status). PowerShell's $LASTEXITCODE and cmd + // print the signed view (-1073741819), but no truncation or masking + // happens anywhere in the chain — the mirror contract holds for the + // full 32-bit range, so no re-mapping is needed. process.exitCode = exitCode }, (error: unknown) => { diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index d9c7c70564..9c0436f29f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -14,9 +14,12 @@ import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' /** - * Quote one argument per the CommandLineToArgvW parsing rules (backslash - * escaping only before quotes; a trailing backslash before the closing quote - * is doubled). + * Quote one argument per the CommandLineToArgvW parsing rules: backslashes + * are doubled only before a quote character — including the closing quote + * this function appends, so a trailing backslash run is doubled as well + * (otherwise an odd run would escape the closing quote into a literal + * character and corrupt the rest of the command line). Mirrors the CRT + * ArgvQuote behavior Microsoft documents for command-line arguments. * @param argument - one argv entry to quote. * @returns the quoted entry (bare when quoting is unnecessary). */ @@ -30,10 +33,13 @@ export function quoteArg(argument: string): string { backslashes++ index++ } - if (index < argument.length && argument.charAt(index) === '"') { + if (index === argument.length) { + // Trailing backslash run: doubled so it cannot escape the closing quote. + quoted += '\\'.repeat(backslashes * 2) + } else if (argument.charAt(index) === '"') { quoted += '\\'.repeat(backslashes * 2 + 1) + '"' } else { - quoted += '\\'.repeat(backslashes) + (index < argument.length ? argument.charAt(index) : '') + quoted += '\\'.repeat(backslashes) + argument.charAt(index) } } return quoted + '"' @@ -119,8 +125,19 @@ export function spawnSandboxed( null, options.cwd, startupInfo, processInfo, ) - // Capture the failure before CloseHandle calls clobber GetLastError. - if (created === 0) throwLastError(api, 'CreateProcessAsUserW', `command: ${options.command}, cwd: ${options.cwd}`) + // Capture the failure before CloseHandle calls clobber GetLastError, then + // close every pipe handle created so far — the six-close contract this test + // surface pins (tests/failure-paths.spec.ts). + if (created === 0) { + const win32Code = api.getLastError() + api.closeHandle(stdIn.read) + api.closeHandle(stdIn.write) + api.closeHandle(stdOut.read) + api.closeHandle(stdOut.write) + api.closeHandle(stdErr.read) + api.closeHandle(stdErr.write) + throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`) + } const info = decodeProcessInfo(processInfo) const processHandle = info.hProcess @@ -172,7 +189,9 @@ export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise< } chunks.push(chunk.subarray(0, decodeUint32(readSlot))) } - await new Promise(resolve => setImmediate(resolve)) + // Small backoff instead of setImmediate: a bare next-tick would busy-poll + // the pipe at full event-loop speed while the child produces no output. + await new Promise(resolve => setTimeout(resolve, 1)) } api.closeHandle(handle) return Buffer.concat(chunks) @@ -314,7 +333,16 @@ export function spawnSandboxedInherited( api.closeHandle(job) throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`) } - if (api.resumeThread(threadHandle) === 0xFFFFFFFF) throwLastError(api, 'ResumeThread', `pid ${info.dwProcessId}`) + if (api.resumeThread(threadHandle) === 0xFFFFFFFF) { + // Closing the job triggers kill-on-close, so the suspended child dies + // instead of hanging until this process exits; the process/thread handles + // must go too. + const win32Code = api.getLastError() + api.closeHandle(threadHandle) + api.closeHandle(processHandle) + api.closeHandle(job) + throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`) + } api.closeHandle(threadHandle) return { pid: info.dwProcessId, process: processHandle, job } diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 7696605c4e..942862ceb9 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -49,13 +49,26 @@ export const SE_GROUP_LOGON_ID = 0xC0000000 export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL /** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */ export const FILE_GENERIC_WRITE = 0x00120116 -// What the POC grants: FILE_GENERIC_WRITE minus READ_CONTROL; displays as -// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). +/** DELETE: remove or rename the object (winnt.h line ~3009). */ +export const DELETE = 0x00010000 +/** FILE_DELETE_CHILD: remove or rename a directory's children (winnt.h line ~5907). */ +export const FILE_DELETE_CHILD = 0x0040 +// The POC granted FILE_GENERIC_WRITE minus READ_CONTROL, which displays as +// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). The +// sandbox grant adds DELETE and FILE_DELETE_CHILD so confined +// delete/rename/git operations inside the granted trees pass the token's +// access check too; Write+DELETE displays as "Modify" in icacls. +// WRITE_DAC/WRITE_OWNER stay OUT deliberately — granting them would let the +// child take ownership or rewrite DACLs and escape the allowlist (the +// security boundary). /** - * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL — the write-access mask - * the orphan-SID ACEs grant (displays as "Write" in Explorer/icacls). + * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and + * FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant + * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are + * deliberately excluded: they would let the confined child take ownership or + * rewrite DACLs. */ -export const GRANT_MASK = FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE // 0x00100116 +export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 // CreateRestrictedToken flags (winnt.h lines ~4284) /** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ @@ -68,7 +81,13 @@ export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) /** WinWorldSid: S-1-1-0 (Everyone). */ export const WinWorldSid = 1 -/** WinLocalSid: S-1-2-0 (LOCAL); CreateWellKnownSid(WinLocalSid) fails with ERROR_INVALID_PARAMETER on Windows 11 build 26200. */ +/** + * WinLocalSid: S-1-2-0 (LOCAL) — safe, created successfully on every init + * (it sits in every restricted token's restricting list). The + * CreateWellKnownSid ERROR_INVALID_PARAMETER failure documented in this + * module's header comment belongs to WinLocalLogonSid (S-1-2-1), NOT to + * this type. + */ export const WinLocalSid = 2 /** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ export const WinInteractiveSid = 11 @@ -155,6 +174,39 @@ export const ERROR_INSUFFICIENT_BUFFER = 122 export const ERROR_BROKEN_PIPE = 109 /** ERROR_NO_DATA: the pipe is being closed. */ export const ERROR_NO_DATA = 232 +/** ERROR_LOCK_VIOLATION: a byte-range lock conflicts with an existing lock (winerror.h line ~78). */ +export const ERROR_LOCK_VIOLATION = 33 + +// ---- lock files (fileapi.h / minwinbase.h / winnt.h) ----------------------- + +// CreateFileW dwDesiredAccess for the ACL lock files: plain read+write is +// enough to take byte-range locks. +/** GENERIC_READ: generic read access (winnt.h line ~3028). */ +export const GENERIC_READ = 0x80000000 +/** GENERIC_WRITE: generic write access (winnt.h line ~3029). */ +export const GENERIC_WRITE = 0x40000000 +// CreateFileW dwShareMode: the lock file is shared for read/write but NOT +// for delete — if a locked file could be deleted and recreated underneath the +// lock holder, two processes could hold "the same" lock on different files. +/** FILE_SHARE_READ: other opens may read (winnt.h line ~5949). */ +export const FILE_SHARE_READ = 0x00000001 +/** FILE_SHARE_WRITE: other opens may write (winnt.h line ~5950). */ +export const FILE_SHARE_WRITE = 0x00000002 +/** FILE_SHARE_DELETE: other opens may delete (winnt.h line ~5951) — deliberately NOT used for lock files. */ +export const FILE_SHARE_DELETE = 0x00000004 +/** OPEN_ALWAYS: create the lock file if absent, open it otherwise (fileapi.h line ~21). */ +export const OPEN_ALWAYS = 4 +// LockFileEx dwFlags (minwinbase.h lines ~180-181, included by winbase.h). +/** LOCKFILE_EXCLUSIVE_LOCK: request an exclusive byte-range lock. */ +export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 +/** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ +export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 + +// ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when +// reading a DACL are marked with this bit and are not part of the explicit +// DACL edits this module makes. +/** INHERITED_ACE: the ACE was inherited from the parent object, not stored explicitly. */ +export const INHERITED_ACE = 0x10 // ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) -------------- diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts new file mode 100644 index 0000000000..fe0bc13acc --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -0,0 +1,234 @@ +/** + * ACL edit tests: the read-merge-write grant keeps pre-existing explicit + * ACEs, interleaved sandbox instances do not clobber each other, the + * per-path lock primitive is deterministic, and the grant mask carries + * DELETE + FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER). + * + * All state lives in %TEMP% mkdtemp scratch directories; the only exception + * is the mandated lock infrastructure under \dsh-acl-locks, + * whose per-test lock file is removed in cleanup. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import koffi from 'koffi' + +import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' +import { AclSandbox } from '../src/index.ts' +import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import * as abi from '../src/win32-abi.ts' + +const isWin32 = process.platform === 'win32' + +/** FILE_READ_DATA (winnt.h line ~5895): the harmless mask the explicit test ACE grants. */ +const FILE_READ_DATA = 0x0001 + +/** koffi SID layout: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes, big-endian), subAuthority@8. */ +const SID_STRUCT = koffi.struct('DSH_ACL_SPEC_SID', { + revision: 'uint8', + subAuthorityCount: 'uint8', + identifierAuthority: 'uint8[6]', + subAuthority: 'uint32[8]', +}) + +interface SidLayout { + revision: number + subAuthorityCount: number + identifierAuthority: number[] + subAuthority: number[] +} + +/** One direct (explicit, non-inherited) allow ACE of a directory DACL. */ +interface DirectAce { + sid: string + mask: number +} + +/** Convert one SID string to a LocalAlloc'd SID pointer (caller frees). */ +function sidFromString(api: Win32Bindings, sid: string): NativePtr { + const slot = allocPtrSlot() + if (api.convertStringSidToSidW(sid, slot) === 0) throw new Error(`ConvertStringSidToSidW failed for ${sid}`) + const ptr = decodePtr(slot) + if (ptr === null) throw new Error(`ConvertStringSidToSidW returned null for ${sid}`) + return ptr +} + +/** Stringify a decoded SID layout (identifierAuthority bytes 2..5 are the big-endian value). */ +function sidString(sid: SidLayout): string { + const authority = ((sid.identifierAuthority[2] ?? 0) << 24) + | ((sid.identifierAuthority[3] ?? 0) << 16) + | ((sid.identifierAuthority[4] ?? 0) << 8) + | (sid.identifierAuthority[5] ?? 0) + const subs = sid.subAuthority.slice(0, sid.subAuthorityCount).join('-') + return `S-${sid.revision}-${authority}${sid.subAuthorityCount > 0 ? `-${subs}` : ''}` +} + +/** + * Read the directory's explicit allow ACEs (inherited ACEs excluded): each + * ACE header is AceType@0, AceFlags@1, AceSize@2 (winnt.h lines ~3477-3480); + * ACCESS_ALLOWED_ACE stores Mask@4 and the inline SID@8. The ACL pointer sits + * inside the descriptor allocation — only the descriptor is LocalFree'd. + */ +function readDirectAces(api: Win32Bindings, path: string): DirectAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`) + const acl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: DirectAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const flags = koffi.decode(acl, offset + 1, 'uint8') as number + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + if ((flags & abi.INHERITED_ACE) === 0) { + aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number }) + } + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + +describe.skipIf(!isWin32)('ACL editing', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-')) + scratchDirs.push(dir) + return dir + } + + it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => { + const api = await win32() + const dir = scratch() + const usersSid = sidFromString(api, 'S-1-5-32-545') + const orphanSid = sidFromString(api, 'S-1-4-4242-1') + try { + // Install one explicit ACE (Users + benign read mask) with the + // package's own bindings, exactly like a pre-existing explicit DACL + // entry another sandbox instance or administrator added. + const newAclSlot = allocPtrSlot() + const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(usersSid, abi.GRANT_ACCESS, FILE_READ_DATA), null, newAclSlot) + expect(mergeResult, `SetEntriesInAclW setup (${mergeResult})`).toBe(abi.ERROR_SUCCESS) + const newAcl = decodePtr(newAclSlot) + expect(newAcl).not.toBeNull() + const applyResult = api.setNamedSecurityInfoW( + dir, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, null, null, newAcl, null, + ) + const freed = newAcl === null ? null : api.localFree(newAcl) + expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) + expect(isNullPtr(freed)).toBe(true) + + grantWrite(api, dir, orphanSid) + revokeWrite(api, dir, orphanSid) + + const aces = readDirectAces(api, dir) + expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved + expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed + } finally { + if (!isNullPtr(usersSid)) api.localFree(usersSid) + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { + const api = await win32() + const dir = scratch() + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2' }) + await sandboxA.init() + await sandboxB.init() + sandboxA.dispose() + sandboxB.dispose() + const aces = readDirectAces(api, dir) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(false) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(false) + }) + + it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { + const api = await win32() + const dir = scratch() + const lockPath = lockFilePath(api, dir) + const open = (): NativePtr => api.createFileW( + lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null, + ) + const first = open() + const second = open() + expect(isInvalidHandle(first)).toBe(false) + expect(isInvalidHandle(second)).toBe(false) + try { + expect(api.lockFileEx(first, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(0) + expect(api.getLastError()).toBe(abi.ERROR_LOCK_VIOLATION) + expect(api.unlockFileEx(first, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.unlockFileEx(second, 0, 1, 0, allocOverlapped())).toBe(1) + } finally { + api.closeHandle(first) + api.closeHandle(second) + rmSync(lockPath, { force: true }) + } + }) + + it('withPathLock serializes the action and releases the lock even when the action throws', async () => { + const api = await win32() + const dir = scratch() + const lockPath = lockFilePath(api, dir) + let attempts = 0 + expect(() => withPathLock(api, dir, () => { + attempts++ + throw new Error('action failure') + })).toThrow('action failure') + expect(attempts).toBe(1) + // The lock was released: a fresh immediate lock succeeds. + const handle = api.createFileW( + lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE, + abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null, + ) + expect(isInvalidHandle(handle)).toBe(false) + try { + expect(api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1) + expect(api.unlockFileEx(handle, 0, 1, 0, allocOverlapped())).toBe(1) + } finally { + api.closeHandle(handle) + rmSync(lockPath, { force: true }) + } + }) + + it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => { + const api = await win32() + const dir = scratch() + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5' }) + try { + await sandbox.init() + const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') + expect(grant).toBeDefined() + const mask = grant?.mask ?? 0 + expect(mask).toBe(abi.GRANT_MASK) + expect(mask & abi.DELETE).toBe(abi.DELETE) + expect(mask & abi.FILE_DELETE_CHILD).toBe(abi.FILE_DELETE_CHILD) + expect(mask & 0x00040000).toBe(0) // WRITE_DAC must never be granted + expect(mask & 0x00080000).toBe(0) // WRITE_OWNER must never be granted + } finally { + sandbox.dispose() + } + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts new file mode 100644 index 0000000000..14f370602c --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -0,0 +1,112 @@ +/** + * Failure-path unit tests with minimal stub binding tables: the spawn + * helpers must close every handle they created before throwing, and + * getTempPath must refuse to decode a buffer GetTempPathW never wrote. + * Pure stubs — no real Win32 calls, so these run on every platform. + */ + +import { describe, expect, it, vi } from 'vitest' +import koffi from 'koffi' + +import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts' +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { Win32Error } from '../src/errors.ts' +import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */ +function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { + const closed: bigint[] = [] + let next = 1n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { + koffi.encode(readSlot, PVOID, next++) + koffi.encode(writeSlot, PVOID, next++) + return 1 + }), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(() => 0), + getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */ +function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType } { + const closed: bigint[] = [] + let std = 50n + const closeHandle = vi.fn((handle: NativePtr) => { + closed.push(handle) + return 1 + }) + const api = { + createJobObjectW: vi.fn(() => 100n), + setInformationJobObject: vi.fn(() => 1), + getStdHandle: vi.fn(() => std++), + setHandleInformation: vi.fn(() => 1), + createProcessAsUserW: vi.fn(( + _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, + _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, + ) => { + koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 }) + return 1 + }), + assignProcessToJobObject: vi.fn(() => 1), + resumeThread: vi.fn(() => 0xFFFFFFFF), + getLastError: vi.fn(() => 5), + closeHandle, + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, closed, closeHandle } +} + +describe('spawn failure paths close their handles', () => { + // A dummy token value; the stubbed spawn never reads it. + const token = 1n as NativePtr + + it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => { + const { api, closed, closeHandle } = pipeFailureApi() + let caught: unknown + try { + spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('CreateProcessAsUserW') + expect((caught as Win32Error).win32Code).toBe(5) + expect(closeHandle).toHaveBeenCalledTimes(6) + expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n]) + }) + + it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => { + const { api, closed, closeHandle } = resumeFailureApi() + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('ResumeThread') + expect((caught as Win32Error).win32Code).toBe(5) + // thread, process, job — closing the job triggers kill-on-close so the + // suspended child dies instead of hanging until this process exits. + expect(closeHandle).toHaveBeenCalledTimes(3) + expect(closed).toEqual([201n, 200n, 100n]) + }) +}) + +describe('getTempPath buffer defense', () => { + it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => { + const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer + expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts new file mode 100644 index 0000000000..5af00fb9cb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/quote.spec.ts @@ -0,0 +1,88 @@ +/** + * quoteArg unit tests plus a round-trip through the REAL CommandLineToArgvW + * parser (shell32.dll, shellapi.h line ~867: + * `LPWSTR *CommandLineToArgvW(LPCWSTR lpCmdLine, int *pNumArgs)`) on win32. + * + * CommandLineToArgvW applies the documented backslash rule (2n backslashes + * before a quote produce n backslashes and toggle quoting; 2n+1 produce n + * backslashes and a literal quote) to every token EXCEPT the first — the + * first token is parsed with backslashes literal and quotes toggling + * (verified empirically on this machine, Windows 11 build 26200). The + * round-trip therefore prepends a plain program token, exactly like + * buildCommandLine's real callers do, so the arguments under test land on + * the rule-applying tokens. + * + * Reading argv from CommandLineToArgvW: koffi cannot decode the returned + * LPWSTR* contents directly (the pointed-to strings are not koffi-registered + * references), so each string is copied with lstrcpynW (winbase.h line + * ~1500) into a Node Buffer and read as UTF-16LE; lengths come from + * lstrlenW (winbase.h line ~1506); the argv block is freed with LocalFree + * (winbase.h line ~1127) — CommandLineToArgvW's documented contract. + */ + +import { describe, expect, it } from 'vitest' + +import { buildCommandLine, quoteArg } from '../src/spawn.ts' + +const isWin32 = process.platform === 'win32' + +/** + * Table cases: input argv entry → the exact command-line fragment quoteArg + * must produce. Trailing-backslash inputs are the regression: the closing + * quote must be preceded by DOUBLED backslashes, or the parser reads them as + * escaping the closing quote. + */ +const cases: Array<[input: string, quoted: string]> = [ + ['', '""'], + ['a', 'a'], + ['a b', '"a b"'], + ['a"b', '"a\\"b"'], + ['a\\b', 'a\\b'], + ['a b\\', '"a b\\\\"'], + ['a b\\\\', '"a b\\\\\\\\"'], + ['a b\\\\\\', '"a b\\\\\\\\\\\\"'], + ['a\\\\"b', '"a\\\\\\\\\\"b"'], +] + +describe('quoteArg', () => { + it.each(cases)('quotes %j as %j', (input, quoted) => { + expect(quoteArg(input)).toBe(quoted) + }) +}) + +describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => { + it('parses quoteArg+join back to the exact original argv', async () => { + const { default: koffi } = await import('koffi') + const PVOID = koffi.pointer('void') + const shell32 = koffi.load('shell32.dll') + const kernel32 = koffi.load('kernel32.dll') + const commandLineToArgvW = shell32.func('__stdcall', 'CommandLineToArgvW', PVOID, ['str16', koffi.pointer('int')]) + const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int']) + const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID]) + const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID]) + + const parse = (commandLine: string): string[] => { + const countSlot = koffi.alloc('int', 1) as unknown + const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown + try { + if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL') + const count = koffi.decode(countSlot, 0, 'int') as number + const table = Buffer.from(koffi.view(argvBlock, count * 8)) + const parsed: string[] = [] + for (let index = 0; index < count; index++) { + const stringAddress = table.readBigUInt64LE(index * 8) + const copied = Buffer.alloc(2048) + lstrcpynW(copied, stringAddress, copied.length / 2) + const length = lstrlenW(copied) as number + parsed.push(copied.subarray(0, length * 2).toString('utf16le')) + } + return parsed + } finally { + localFree(argvBlock) + } + } + + const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a b\\\\\\', 'a\\\\"b'] + expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv]) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index f188423425..680d65b253 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -12,16 +12,16 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) +// Functional probe, not where.exe: spawnSync never throws on a missing +// binary (status null) and where.exe exits 1 without pwsh — only an actual +// pwsh invocation's exit status is truth. function pwshAvailable(): boolean { - try { - spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' }) - return true - } catch { - return false - } + return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 } function runRunner(args: string[], timeoutMs = 30_000) { @@ -99,6 +99,31 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) }, 30_000) + it('workspace-write: Remove-Item and Rename-Item succeed in the granted workspace (DELETE + FILE_DELETE_CHILD)', () => { + // Deleting a file and renaming a directory both hit the second access + // check on the workspace itself: the grant must carry DELETE (on the + // object) and FILE_DELETE_CHILD (on its parent). + const victimFile = join(writableDir, 'delete-me.txt') + writeFileSync(victimFile, 'remove me') + const victimDir = join(writableDir, 'rename-me') + mkdirSync(victimDir) + const renamedDir = join(writableDir, 'renamed-by-child') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Remove-Item -LiteralPath '${victimFile}' -ErrorAction Stop;'DELETE-FILE: OK'}catch{'DELETE-FILE: DENIED'};`, + `try{Rename-Item -LiteralPath '${victimDir}' -NewName 'renamed-by-child' -ErrorAction Stop;'RENAME-DIR: OK'}catch{'RENAME-DIR: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('DELETE-FILE: OK') + expect(result.stdout).toContain('RENAME-DIR: OK') + expect(existsSync(victimFile)).toBe(false) + expect(existsSync(renamedDir)).toBe(true) + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 914326acdc..866fda7160 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -88,6 +88,20 @@ int wmain() P(FILE_GENERIC_WRITE); P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE)); P(STANDARD_RIGHTS_WRITE); + P(DELETE); + P(FILE_DELETE_CHILD); + P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE)); + + P(FILE_SHARE_READ); + P(FILE_SHARE_WRITE); + P(FILE_SHARE_DELETE); + P(GENERIC_READ); + P(GENERIC_WRITE); + P(OPEN_ALWAYS); + P(LOCKFILE_EXCLUSIVE_LOCK); + P(LOCKFILE_FAIL_IMMEDIATELY); + P(ERROR_LOCK_VIOLATION); + P(INHERITED_ACE); P(DISABLE_MAX_PRIVILEGE); P(SANDBOX_INERT); @@ -163,7 +177,14 @@ int wmain() static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write"); - static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "grant mask"); + static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "poc grant mask"); + static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights"); + static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask"); + static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes"); + static_assert(OPEN_ALWAYS == 4, "open always"); + static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags"); + static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation"); + static_assert(INHERITED_ACE == 0x10, "inherited ace flag"); static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes"); static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance"); static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b06386f144..d08966c17a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4654,6 +4654,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../sandbox-local From abfb9336208a8051f24f9a1e0d354fc311eb63ea Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:29:43 +0800 Subject: [PATCH 29/81] feat(sandbox): per-session windows-acl write grant with dual-mode restricting lists and a private temp subdirectory --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 7 + docs/core-data-structures/sandbox.zh.md | 7 + docs/persistence-catalog.md | 23 ++ knip.json | 1 + packages/bundle/base/README.i18n.yaml | 4 +- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/pty/pty-local/tests/index.spec.ts | 2 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/sandbox/sandbox-local/package.json | 2 + .../sandbox/sandbox-local/src/acl-session.ts | 103 +++++++ packages/sandbox/sandbox-local/src/index.ts | 154 +++++++++- .../sandbox-local/tests/acl-session.spec.ts | 275 ++++++++++++++++++ packages/sandbox/sandbox-local/tsconfig.json | 6 + packages/sandbox/sandbox-policy/src/index.ts | 1 + .../sandbox-policy/tests/policy.spec.ts | 4 + .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 32 +- .../sandbox/sandbox-windows-acl/README.zh.md | 32 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 63 +++- .../sandbox/sandbox-windows-acl/src/ffi.ts | 89 +++++- .../sandbox/sandbox-windows-acl/src/grant.ts | 95 ++++++ .../sandbox/sandbox-windows-acl/src/index.ts | 60 +++- .../sandbox/sandbox-windows-acl/src/runner.ts | 45 ++- .../sandbox/sandbox-windows-acl/src/spawn.ts | 6 +- .../sandbox/sandbox-windows-acl/src/token.ts | 39 ++- .../sandbox-windows-acl/src/win32-abi.ts | 9 + .../sandbox-windows-acl/tests/acl.spec.ts | 30 +- .../tests/grant-failure-paths.spec.ts | 100 +++++++ .../sandbox-windows-acl/tests/grant.spec.ts | 69 +++++ .../sandbox-windows-acl/tests/probe.spec.ts | 4 +- .../sandbox-windows-acl/tests/runner.spec.ts | 50 +++- packages/sandbox/sandbox/src/index.ts | 7 + 40 files changed, 1239 insertions(+), 118 deletions(-) create mode 100644 packages/sandbox/sandbox-local/src/acl-session.ts create mode 100644 packages/sandbox/sandbox-local/tests/acl-session.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/src/grant.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 9bb02e92b6..7a11b5e95f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: db793ab31a853a6627b2e30f4dd3f71542e3836d -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: a3d69a2dd94de2fa4ef83aa16ee297c1939b767d +2026-08-08-windows-acl-restricted-token-sandbox.md: e93a066aca1a39fb177a7ae3c2f71f44724befd7 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e4d5254504d2f2318c6b7646f09156dfef4d7619 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index db793ab31a..e93a066aca 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a per-instance orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID, Everyone, orphan — no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed) and list J (`workspace-write` = + Authenticated Users, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by `dispose()`); the workspace-write temp grant is the real temp directory — the same backend-defined choice the Landlock rung makes. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented). ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation — Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index a3d69a2dd9..e4d5254504 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含每个实例独有的孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 登录 SID、Everyone、孤儿——不含 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J(`workspace-write` = + Authenticated Users,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由 `dispose()` 回收);workspace-write 的临时授权是真实临时目录——与 Landlock 档相同的后端定义选择。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录)。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针)钉住。 ## Related diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4697e1267..f29675a109 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1181,7 +1181,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:28`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -2019,7 +2019,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:43`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:47`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4a73dc06ad..cc645fa531 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:148`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:155`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index be2c311fd1..dd31511e20 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md -sandbox.md: af2b9043c52f35b7f38b368de1420d0e2856a963 -sandbox.zh.md: 5638e76769639525b1884756805d0e0cef1e870e +sandbox.md: a9a1fec080e1cf86ea63e02e062b775cd6d4d0da +sandbox.zh.md: 99505265a9c440a14cc0cfc5473823ca5514984c diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index af2b9043c5..a9a1fec080 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -53,6 +53,13 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 5638e76769..99505265a9 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -53,6 +53,13 @@ interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } ``` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 48732dbbb0..c0e25df4cb 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -477,6 +477,29 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ### `sandbox/*` +#### `sandbox/acl-session` — log-only + +```ts persistence-catalog +/** + * The session's windows-acl write identity was provisioned — log-only + * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): + * durable and replayable, never in the model transcript. The LAST such + * event is the session's record ({@link sessionAclRecord}); the + * provider appends exactly one on the session's first Windows confined + * execution. + */ +'sandbox/acl-session': { + /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ + workspace: string + /** The session's private temp subdirectory under the host temp root. */ + tempDir: string +} +``` + +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:34`](../packages/sandbox/sandbox-local/src/acl-session.ts) + #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/knip.json b/knip.json index f90e6c8f8d..e938425ffd 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "icacls", "python3", "sandbox-exec", "taskkill", diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 19f2c66290..cd371b4878 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: de89e8c5d1fcf78fcd47ebdb98c1d1fc3bb0bd5f -README.zh.md: 45ab3d9fa3c750984d903475b3c9e027e9e95d1c +README.md: 2fb5c7cdec44d023abd31b0913acdeead9e54eaf +README.zh.md: d8368dbd371be0f4b7530ba956b7cd7507486115 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index de89e8c5d1..2fb5c7cdec 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -19,4 +19,4 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **The Windows temp grant is the real temp directory** — `workspace-write` confines writes to the workspace plus the host temp area (the same backend-defined choice the Landlock rung makes); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. +- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 45ab3d9fa3..d8368dbd37 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -19,4 +19,4 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 -- **Windows 的临时目录授权是真实 temp 目录**——`workspace-write` 把写入限制在工作区与宿主 temp 区域(与 Landlock 档位相同的后端定义选择);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b7fd6d3c5a..7e2e8e5c39 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: string;\n}', }, { name: 'SandboxMode', diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 1ad29d7d01..6db4d0b297 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -176,7 +176,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace', sessionId: 'agent' }, }]) }) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c57b0ebb8e..c11a40ebc6 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -124,7 +124,7 @@ describe('pty-local real shell', () => { const created = await ctx.pty.spawn(agent, { type: 'shell' }) expect(sandbox.calls).toEqual([{ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) }, + policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root), sessionId: 'agent-workspace-write' }, }]) await fiber.dispose() expect(ctx.pty.listBackends()).toEqual([]) diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 060026e284..c52b9c66fa 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -39,6 +40,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts new file mode 100644 index 0000000000..c820694b11 --- /dev/null +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -0,0 +1,103 @@ +/** + * The windows-acl per-session write identity — the DURABLE half of the seam's + * per-session grant reuse. Each session owns exactly one record (one orphan + * write SID, one private temp subdirectory), stored as a log-only + * `sandbox/acl-session` event on the session log (the `sandbox/mode` + * precedent): replayable, never in the model transcript, and no external + * config store. The ACE half is server-lifetime state owned by the provider + * ({@link AclWriteGrant} materialization, revoked on dispose); the record + * survives restarts so a resumed session reuses the SAME SID — re-granting + * idempotently merges into (or skips) the standing ACEs instead of leaking a + * fresh dead SID's ACEs per restart. A fork gets a new session id and thus a + * fresh record; the record's workspace must match the session's immutable + * cwd (asserted by the provider). + * + * @module dsh-sandbox-local/acl-session + */ + +import { createHash } from 'node:crypto' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * The session's windows-acl write identity was provisioned — log-only + * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): + * durable and replayable, never in the model transcript. The LAST such + * event is the session's record ({@link sessionAclRecord}); the + * provider appends exactly one on the session's first Windows confined + * execution. + */ + 'sandbox/acl-session': { + /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ + workspace: string + /** The session's private temp subdirectory under the host temp root. */ + tempDir: string + } + } +} + +/** The durable per-session record carried by one `sandbox/acl-session` event. */ +export interface AclSessionRecord { + /** The orphan write SID whose ACEs form the session's write allowlist. */ + writeSid: string + /** The workspace root the record was provisioned for. */ + workspace: string + /** The session's private temp subdirectory. */ + tempDir: string +} + +/** + * The session's windows-acl record: the last `sandbox/acl-session` event in + * the log, or undefined when the session has none (never confined on + * Windows). The pure fold — resume needs no catch-up machinery because + * replaying the log IS the state. + * @param events - session events in log order (other event types are skipped). + * @returns the last provisioned record, or undefined without one. + */ +export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'sandbox/acl-session') return event.data + } + return undefined +} + +/** + * The session's private temp subdirectory: `\dsh-`. Deterministic from the session id, so it converges + * across server restarts (the same SID re-grants the same directory) and OS + * temp hygiene may reclaim it — deliberately no GC here. + * @param sessionId - the session identity. + * @returns the private temp subdirectory path. + */ +export function sessionTempDir(sessionId: string): string { + const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) + return join(tmpdir(), `dsh-${digest}`) +} + +/** + * Provision the record for a session that has none (its first Windows + * confined execution): a fresh write SID plus the private temp + * subdirectory, appended as exactly one log-only `sandbox/acl-session` + * event — the provision IS its event, nothing mutates record state out of + * band. Fork (new session id) provisions a fresh record; resume replays the + * stored one. + * @param session - the session the record belongs to. + * @param workspaceRoot - the resolved policy root (the session's immutable cwd). + * @returns the provisioned record. + */ +export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { + const record: AclSessionRecord = { + writeSid: randomWriteSid(), + workspace: workspaceRoot, + tempDir: sessionTempDir(session.id), + } + session.append('sandbox/acl-session', record) + return record +} diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 0b562616ea..183c6200ed 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -4,11 +4,18 @@ * competing candidates once, and reports each wrap's enforcement and stderr * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. + * + * The windows-acl rung additionally owns the per-session write grant: one + * orphan write SID and one private temp subdirectory per session (durable + * record in the session log — see `./acl-session.ts`), ACEs materialized + * lazily at the session's first confined execution and held for the SERVER + * process's lifetime (revoked on dispose). The runner receives `--write-sid` + * and stops managing DACLs itself. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { @@ -22,6 +29,10 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId } from '@deepseek-ai/dsh-session' +import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { provisionAclSession, sessionAclRecord } from './acl-session.ts' +import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -205,9 +216,10 @@ const RUNNER_FAILURE_RULES = { } as const satisfies Record /** - * Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless - * apart from the cached chain verdict — it spawns nothing but the one-time - * probes, so there is no disposal work beyond cordis' own. + * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the + * chain verdict and, on the windows-acl rung, the per-session write grants + * ({@link AclWriteGrant}, one per session, revoked on provider dispose); the + * one-time probes spawn nothing else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -225,6 +237,12 @@ export class LocalSandboxProvider extends SandboxProvider { private readonly probeTimeoutMs: number /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined + /** + * Server-lifetime per-session write grants (windows-acl rung), keyed by the + * session's orphan write SID — the native half of the per-session reuse; + * the durable half lives in the session log (`./acl-session.ts`). + */ + private readonly aclGrants = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -246,6 +264,12 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) + // Standing ACL grants are revoked with the provider: a clean server + // shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs + // the session's durable record re-grants idempotently on resume). + ctx.effect(() => () => { + this.revokeAclGrants() + }) } /** @@ -284,18 +308,124 @@ export class LocalSandboxProvider extends SandboxProvider { case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)] case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)] case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)] - case 'windows-acl': return [ - ...this.windowsAclRunnerInvocation(), - '--workspace', policy.workspaceRoot, - // Explicit, never GetTempPathW-defaulted: the runner grants exactly - // this directory (workspace-write) or nothing (read-only). - '--temp', tmpdir(), - '--mode', policy.mode, - ] + case 'windows-acl': return this.windowsAclRunnerArgv(policy) default: return assertNever(runner) } } + /** + * The windows-acl runner argv for one policy. With a calling session + * (the policy's `sessionId`), the session's durable record is folded from + * the session log (provisioned on first use), its ACEs materialized once + * per server lifetime, and the runner receives `--write-sid` plus the + * session's PRIVATE temp subdirectory — it grants nothing and revokes + * nothing. Agentless calls (no session) pass no SID: the runner + * self-manages per-call grants on the ambient temp root. + * @param policy - the resolved per-call policy. + * @returns the runner invocation. + */ + private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { + const sessionId = policy.sessionId + const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot) + if (record !== undefined) this.materializeAclGrant(record, policy.mode) + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + // Workspace-write sessions confine their temp writes to the PRIVATE + // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only + // and agentless runs pass the ambient temp root — the runner validates + // it exists but grants nothing (or self-manages, agentless only). + '--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(), + '--mode', policy.mode, + ...record === undefined ? [] : ['--write-sid', record.writeSid], + ] + } + + /** + * Fold (or provision) the calling session's durable windows-acl record. + * The provision appends exactly one log-only `sandbox/acl-session` event + * to the session log; the record's workspace must equal the policy root — + * both derive from the session's immutable cwd, so a mismatch is a + * corrupted composition and fails loud. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @returns the session's record. + */ + private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord { + const store = this.ctx.get('sessions') + if (store === undefined) { + throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') + } + const session = store.get(SessionId(sessionId)) + if (session === undefined) { + throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) + } + const existing = sessionAclRecord(session.events) + if (existing !== undefined) { + if (existing.workspace !== workspaceRoot) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record workspace ${JSON.stringify(existing.workspace)} ` + + `does not match the resolved policy root ${JSON.stringify(workspaceRoot)} (session cwd is immutable)`, + ) + } + return existing + } + return provisionAclSession(session, workspaceRoot) + } + + /** + * Materialize the record's ACEs once per server lifetime: lazily at the + * session's first confined execution, reused for every later call (the map + * hit is the whole call). Workspace-write grants the workspace root and + * the private temp subdirectory (created here); read-only materializes + * NOTHING — its token alone restricts every write. Fail-closed: a + * half-materialized grant is revoked before the error propagates. + * @param record - the session's durable record. + * @param mode - the policy mode (grants exist only under workspace-write). + */ + private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { + if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return + const grant = AclWriteGrant.create(record.writeSid) + try { + mkdirSync(record.tempDir, { recursive: true }) + grant.add(record.workspace) + grant.add(record.tempDir) + } catch (error) { + // Revoke whatever stands and free the SID — never leave a half-grant + // behind a failed confine (the runner never runs). + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed') + } + throw error + } + this.aclGrants.set(record.writeSid, grant) + } + + /** + * Revoke every standing per-session grant and free every SID (provider + * dispose). Cleanup failures are reported, not thrown: cordis teardown + * must not be aborted by grant revocation, and the durable records make a + * missed revocation self-healing on the next resume. + */ + private revokeAclGrants(): void { + if (this.aclGrants.size === 0) return + const failures: unknown[] = [] + for (const grant of this.aclGrants.values()) { + try { + grant.dispose() + } catch (error) { + failures.push(error) + } + } + this.aclGrants.clear() + if (failures.length > 0) { + this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) + for (const error of failures) this.ctx.logger.warn(error) + } + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts new file mode 100644 index 0000000000..b39451d8ea --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -0,0 +1,275 @@ +/** + * The windows-acl per-session grant: the DURABLE record (session-log event + * fold/provision) plus the SERVER-LIFETIME ACE materialization + * ({@link AclWriteGrant}), exercised through the REAL + * LocalSandboxProvider.confine() with a real session store. The Win32 surface + * is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`), + * so these assertions run in every CI lane that runs sandbox-local's suites; + * the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows- + * acl's own tests on win32 hosts. + */ + +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { sessionTempDir } from '../src/acl-session.ts' + +/** Cross-file state shared with the vi.mock factory (hoisting contract). */ +const mockState = vi.hoisted(() => ({ + grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>, + addFailure: undefined as Error | undefined, + disposeFailure: undefined as Error | undefined, +})) + +vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { + class MockAclWriteGrant { + readonly writeSid: string + readonly added: string[] = [] + disposed = false + constructor(writeSid: string) { + this.writeSid = writeSid + mockState.grants.push(this) + } + static create(writeSid: string): MockAclWriteGrant { + return new MockAclWriteGrant(writeSid) + } + add(path: string): void { + if (mockState.addFailure !== undefined) throw mockState.addFailure + this.added.push(path) + } + dispose(): void { + if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure + this.disposed = true + } + } + return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' } +}) + +/** One provisioned record event, shaped like the live log's envelope. */ +function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent { + return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(LocalSandboxProvider, {}) + const sandbox = ctx.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + return { ctx, sandbox, fiber } +} + +/** A workspace root the policy, the record, and the session cwd all share. */ +function workspaceRoot(): string { + return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) +} + +describe('windows-acl per-session grant (LocalSandboxProvider)', () => { + const scratch: string[] = [] + + beforeEach(() => { + mockState.grants = [] + mockState.addFailure = undefined + mockState.disposeFailure = undefined + }) + + const cleanup = () => { + for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) + } + + it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-1') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' } + + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', privateTemp, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) + expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + + // Reuse: the SECOND confine is the map hit — no new grant, no new event. + const second = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(second.argv).toEqual(confined.argv) + expect(mockState.grants).toHaveLength(1) + expect(session.events).toHaveLength(1) + + // Provider dispose revokes the standing grant. + await fiber.dispose() + expect(mockState.grants[0]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + + it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-ro') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' } + + const confined = sandbox.confine(['true'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--mode', 'read-only', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(0) + expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + + it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { + try { + const ws = workspaceRoot() + scratch.push(ws) + const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') } + scratch.push(record.tempDir) + + const first = await setup() + const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) + // The constructor appends the `session/end-seed` marker, so the log is + // the seed plus that marker — exactly one acl record among them. + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' } + const confined = first.sandbox.confine(['true'], policy) + expect(confined.argv).toContain('S-1-4-77-1') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] }) + // Replay IS the state: the seeded record satisfies the fold, nothing appended. + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + expect(session.events).toHaveLength(2) + } finally { + cleanup() + } + }) + + it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') } + ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' } + expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it('fails loud without the session store, and when the policy names a session the store does not hold', async () => { + try { + const bare = new Context() + await bare.plugin(LocalSandboxProvider, {}) + const sandbox = bare.sandbox as LocalSandboxProvider + sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' } + expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) + + const { sandbox: withStore } = await setup() + expect(() => withStore.confine(['true'], policy)).toThrow(/no such session/) + } finally { + cleanup() + } + }) + + it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' } + + // add() throws on the FIRST path: the cleanup dispose() runs and the + // original error propagates unchanged. + mockState.addFailure = new Error('grant exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) + + // add() AND dispose() both throw: both surface as an AggregateError. + mockState.grants = [] + mockState.addFailure = new Error('grant exploded again') + mockState.disposeFailure = new Error('cleanup exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) + } finally { + cleanup() + } + }) + + it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no session store involved', async () => { + try { + const { sandbox, fiber } = await setup() + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } + const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', '/ws', + '--temp', tmpdir(), + '--mode', 'workspace-write', + '--', + 'pwsh', '/Command', 'x', + ]) + expect(mockState.grants).toHaveLength(0) + // Disposing a provider with no grants is a no-op (the empty-map guard). + await fiber.dispose() + } finally { + cleanup() + } + }) + + it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(1) + + mockState.disposeFailure = new Error('revoke exploded') + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) + } finally { + cleanup() + } + }) +}) diff --git a/packages/sandbox/sandbox-local/tsconfig.json b/packages/sandbox/sandbox-local/tsconfig.json index 608f0e9568..9effc139c3 100644 --- a/packages/sandbox/sandbox-local/tsconfig.json +++ b/packages/sandbox/sandbox-local/tsconfig.json @@ -23,6 +23,12 @@ { "path": "../sandbox" }, + { + "path": "../sandbox-windows-acl" + }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 4c87cbc8b3..3a3aac1d70 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -137,6 +137,7 @@ export class SandboxPolicyService extends Service { return { mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode, workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot), + ...session === undefined ? {} : { sessionId: session.id }, } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index c535847f58..11552f2a3a 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -69,10 +69,12 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({ mode: 'workspace-write', workspaceRoot: resolve('/projects/first'), + sessionId: 'sess-first', }) expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({ mode: 'read-only', workspaceRoot: resolve('/projects/second'), + sessionId: 'sess-second', }) expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only') @@ -98,6 +100,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({ mode: 'workspace-write', workspaceRoot: realpathSync.native(physical), + sessionId: 'sess-symlink-parent', }) } finally { rmSync(root, { recursive: true, force: true }) @@ -111,6 +114,7 @@ describe('SandboxPolicyService', () => { expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({ mode: 'danger-full-access', workspaceRoot: resolve('/projects/approved'), + sessionId: 'sess-approved', }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ad25c7e95f..875a1a441d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: d4f99c44f0542e9ed6e7d4605730ad0f0311a22c -README.zh.md: 9b51ccf7b29aefc3dea9a1c97bad75c2f41f5f98 +README.md: 1969515f6692eda059fd5e83449d68c6a4da5c4f +README.zh.md: 4c751ba6dc45bca8cd600b0313b342e37e9ebe54 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index d4f99c44f0..1969515f66 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only this sandbox instance has added to the workspace and temp directories' DACLs. Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -15,7 +15,9 @@ import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) @@ -24,23 +26,25 @@ const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` -Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` grants and revokes per instance (one allowlist per spawn cycle). The server-side per-session reuse is the `AclWriteGrant` class: one instance per session, `add()` per directory, `dispose()` on provider shutdown — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: ```sh -node runner.js --workspace --temp --mode -- +node runner.js --workspace --temp --mode [--write-sid ] -- ``` The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -Modes: -- `workspace-write`: the workspace and temp directories carry the orphan-SID Write grant; every other write is denied by the token intersection. -- `read-only`: STRICT zero grants — nothing is writable. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Documented behavior, not a prompt promise — the model-facing surface makes no sink claims for read-only mode. +**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns. +Modes (the token's restricting-SID list follows the mode): +- `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. +- `read-only` (list I = logon SID, Everyone, orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. + +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. ## Header verification @@ -56,9 +60,11 @@ The koffi struct definitions assert their sizes against the probe at module load - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. -- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). A defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-`); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. +- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -70,7 +76,9 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work -- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root. +- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. -- **Each confined command mutates two directory DACLs** — a grant on entry and a revoke on exit, on the workspace root and the temp root: a handful of Win32 calls per command (inheritance is evaluated lazily per access, not a per-file walk). The runner pays this per command; reusing one grant per session is deferred work if the churn ever matters. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. +- **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. +- **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9b51ccf7b2..4c751ba6dc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,7 +4,7 @@ 面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 只被本沙盒实例加到工作区与临时目录的 DACL 上。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 @@ -15,7 +15,9 @@ import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot] }) +// mode selects the token's restricting-SID list (see Modes below) and must +// match the grant shape: read-only pairs with zero grants. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) @@ -24,23 +26,25 @@ const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes all standing grants; reports every cleanup failure ``` -本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 +直接使用 `AclSandbox` 时按实例授权与回收(每个 spawn 周期一个白名单)。服务器侧的按会话复用是 `AclWriteGrant` 类:每个会话一个实例,每个目录一次 `add()`,提供方关闭时 `dispose()` ——见下方 runner 契约。本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 ## 隔离 runner 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: ```sh -node runner.js --workspace --temp --mode -- +node runner.js --workspace --temp --mode [--write-sid ] -- ``` runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 -模式: -- `workspace-write`:工作区与临时目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`:**严格零授权**——没有任何可写位置。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。这是文档化的行为,不是给模型的承诺——模型可见面没有对 read-only 模式做过任何 sink 承诺。 +**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 -`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API。 +模式(令牌的 restricting SID 列表随模式而定): +- `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 +- `read-only`(列表 I = 登录 SID、Everyone、孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 + +`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 ## 头文件查证 @@ -56,9 +60,11 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 - **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-`);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 +- **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 ## 模型体验 @@ -70,7 +76,9 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ## 已知限制与后续工作 -- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例。 +- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **每次受限命令都会改动两个目录的 DACL** —— 进入时授权、退出时撤销,分别作用在工作区根与临时根:每次命令若干次 Win32 调用(继承按访问惰性求值,不是逐文件遍历)。runner 按命令付这笔开销;按会话复用一次授权留作后续工作,待开销真的成为问题再实现。 +- **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 +- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 +- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 7700474d5f..ef787cc410 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -16,7 +16,7 @@ import { createHash } from 'node:crypto' import { mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' -import { allocOverlapped, allocPtrSlot, decodePtr, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' +import { allocOverlapped, allocPtrSlot, decodePtr, decodeUint8At, decodeUint16At, decodeUint32At, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, sameSidAt, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import * as abi from './win32-abi.ts' @@ -26,6 +26,10 @@ import * as abi from './win32-abi.ts' * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which * removes every ACE for the trustee. + * @param sidPtr - the trustee SID the entry names. + * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). + * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @returns the packed entry buffer. */ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) @@ -174,13 +178,52 @@ function mergeAndApply( if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) } +/** + * True when the explicit DACL already carries the EXACT write grant this + * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the + * orphan SID). Every field is read through koffi.decode at pointer offsets — + * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the + * ACE after the 4-byte mask — there is no pointer to read; reading one + * yields garbage addresses and crashed EqualSid, verified by gdb), so it is + * compared field-by-field against the orphan SID through bounded offset + * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" + * so the caller falls back to the merge-apply path, which owns the robust + * failure handling. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). + * @param sidPtr - the orphan write SID to match. + * @returns whether the exact grant ACE is already present. + */ +function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(oldAcl, 2) + const aceCount = decodeUint16At(oldAcl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path + let offset = 8 // the first ACE follows the 8-byte ACL header + for (let index = 0; index < aceCount; index++) { + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); + // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. + const aceSize = decodeUint16At(oldAcl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path + const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE + && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK + if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false +} + /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID - * on `path`, inheriting to subcontainers and objects. Read-merge-write: the - * new ACE merges into the directory's CURRENT explicit DACL (same shape as - * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the - * per-path lock. The directory must be owned by the caller (owner implicit - * WRITE_DAC) — same precondition as the POC. + * on `path`, inheriting to subcontainers and objects. Idempotent: when the + * directory's current explicit DACL already carries the exact ACE (the + * per-session grant surviving from a previous server lifetime), the + * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate + * the identical ACE across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write: the new ACE merges into the + * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so + * pre-existing explicit ACEs survive. Runs under the per-path lock. The + * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL gains the grant (the workspace or temp root). * @param sidPtr - the orphan write SID the ACE names. @@ -188,6 +231,14 @@ function mergeAndApply( export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { withPathLock(api, path, () => { const { oldAcl, descriptor } = readCurrentDacl(api, path) + if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { + // The exact ACE stands: releasing the descriptor is the whole operation. + if (descriptor !== null) { + const freed = api.localFree(descriptor) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) + } + return + } mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 1d9665a8fc..dd0124edda 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -70,7 +70,6 @@ export interface Win32Bindings { localFree(memory: NativePtr): NativePtr // ---- SIDs ---------------------------------------------------------------- convertStringSidToSidW(stringSid: string, sid: NativePtr): number - convertSidToStringSidW(sid: NativePtr, stringSid: NativePtr): number createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number isValidSid(sid: NativePtr): number getLengthSid(sid: NativePtr): number @@ -111,6 +110,7 @@ export interface Win32Bindings { inheritHandles: number, creationFlags: number, environment: null, currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr, ): number + setEnvironmentVariableW(name: string, value: string): number readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number peekNamedPipe( pipe: NativePtr, buffer: null, size: number, @@ -219,16 +219,6 @@ export function decodeUint32(slot: NativePtr): number { return value as number } -/** - * Decode a UTF-16 string at a pointer. - * @param ptr - pointer to the NUL-terminated UTF-16 string. - * @returns the decoded string. - */ -export function decodeStr16(ptr: NativePtr): string { - const value: unknown = koffi.decode(ptr, 'str16') - return value as string -} - /** * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing). * @param ptr - the koffi pointer. @@ -272,6 +262,69 @@ export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null { return value as NativePtr } +/** + * Decode a uint8 at a native pointer plus byte offset — the ACL walk's + * field-read primitive (koffi.decode with an offset, no memcpy, no pointer + * arithmetic). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint8. + */ +export function decodeUint8At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint8') + return value as number +} + +/** + * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint16. + */ +export function decodeUint16At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint16') + return value as number +} + +/** + * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}). + * @param ptr - the native pointer to read from. + * @param offset - byte offset from the pointer. + * @returns the decoded uint32. + */ +export function decodeUint32At(ptr: NativePtr, offset: number): number { + const value: unknown = koffi.decode(ptr, offset, 'uint32') + return value as number +} + +/** + * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count, + * identifier authority, subauthorities up to the count) — never a fixed-size + * struct decode, which would read past a short SID allocation (a SID with + * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible + * subauthority count reads as unequal. + * @param left - pointer to one SID (offset 0). + * @param leftOffset - byte offset of the SID structure within `left`. + * @param right - pointer to the other SID. + * @param rightOffset - byte offset of the SID structure within `right`. + * @returns whether the SIDs are identical. + */ +export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean { + const leftRevision = decodeUint8At(left, leftOffset) + const rightRevision = decodeUint8At(right, rightOffset) + if (leftRevision !== rightRevision) return false + const leftCount = decodeUint8At(left, leftOffset + 1) + const rightCount = decodeUint8At(right, rightOffset + 1) + if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false + for (let index = 0; index < 6; index++) { + if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false + } + for (let index = 0; index < leftCount; index++) { + if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false + } + return true +} + /** * Allocate a zeroed STARTUPINFOW. * @returns the allocated struct pointer. @@ -331,7 +384,6 @@ function bindings(): Win32Bindings { localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']), localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]), convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]), - convertSidToStringSidW: bind(advapi32, 'ConvertSidToStringSidW', 'int', [PVOID, koffi.pointer('str16')]), createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]), isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]), getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), @@ -356,6 +408,7 @@ function bindings(): Win32Bindings { PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16', koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION), ]), + setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']), readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]), peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]), waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']), @@ -378,6 +431,18 @@ export function win32(): Promise { return Promise.resolve(bindings()) } +/** + * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's + * server-side per-session grant materializes ACEs inside the synchronous + * `confine()` call, which cannot await. Same cached table as {@link win32} + * (the underlying koffi loads are synchronous; the async wrapper exists for + * the runner's await-shaped call sites). + * @returns the cached binding table. + */ +export function win32Sync(): Win32Bindings { + return bindings() +} + /** * Turn a Win32 error code into readable text via FormatMessageW. * @param api - the binding table. diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts new file mode 100644 index 0000000000..4a0f6f4dec --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -0,0 +1,95 @@ +/** + * Server-side per-session write grant: the ACE materialization half of the + * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE + * {@link AclWriteGrant} per session for the server process's lifetime — + * created lazily at the session's first confined execution, reused (never + * re-applied) for every later call, revoked on provider dispose. The durable + * half (the session's SID and paths surviving a restart) lives in the + * session log, owned by the seam; this module owns only the native half: the + * parsed SID pointer and the standing ACEs. + * + * Fail-closed: `add` throws on any grant failure and the caller disposes the + * instance (revoking every path granted so far); `dispose` revokes every + * standing grant and reports every cleanup failure. + * @module @deepseek-ai/dsh-sandbox-windows-acl/grant + */ + +import { grantWrite, revokeWrite } from './acl.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' +import type { NativePtr, Win32Bindings } from './ffi.ts' + +/** + * One orphan write SID's server-lifetime grant materialization: the parsed + * SID pointer plus every directory whose DACL currently carries its ACE. + * Create with {@link AclWriteGrant.create}; dispose revokes all. + */ +export class AclWriteGrant { + /** The orphan write SID in SDDL string form. */ + readonly writeSid: string + private readonly api: Win32Bindings + private readonly sidPtr: NativePtr + private readonly grantedPaths: string[] = [] + + private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + this.api = api + this.sidPtr = sidPtr + this.writeSid = writeSid + } + + /** + * Parse the SID string and open the binding table (lazily, once per + * server). Fail-closed: any failure throws — nothing is granted yet. + * @param writeSid - the orphan write SID string (`S-1-4-x-y`). + * @param api - optional already-resolved bindings (tests). + * @returns the ready grant (no ACEs yet). + */ + static create(writeSid: string, api?: Win32Bindings): AclWriteGrant { + const bindings = api ?? win32Sync() + const sidSlot = allocPtrSlot() + if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) { + throwLastError(bindings, 'ConvertStringSidToSidW', writeSid) + } + const sidPtr = decodePtr(sidSlot) + if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) + return new AclWriteGrant(bindings, sidPtr, writeSid) + } + + /** + * Grant the write ACE on one directory (idempotent: an already-standing + * exact ACE skips the eager full-tree re-propagation — see + * {@link grantWrite}) and record the path for {@link dispose}. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. + * @param path - the directory whose DACL gains the grant. + */ + add(path: string): void { + grantWrite(this.api, path, this.sidPtr) + this.grantedPaths.push(path) + } + + /** Every directory currently carrying the grant, in grant order. */ + get paths(): readonly string[] { + return this.grantedPaths + } + + /** Revoke every standing grant and free the SID; reports every cleanup failure. */ + dispose(): void { + const failures: unknown[] = [] + for (const path of this.grantedPaths) { + try { + revokeWrite(this.api, path, this.sidPtr) + } catch (error) { + failures.push(error) + } + } + try { + const freed = this.api.localFree(this.sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') + } catch (error) { + failures.push(error) + } + if (failures.length > 0) { + throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index e312a40e09..1ff712f24e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -19,7 +19,10 @@ * - grants are standing ACE mutations on real directories — revoke them via * dispose() before the process exits (the POC's documented * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use - * this module's revoke instead). + * this module's revoke instead). With `manageDacls: false` the CALLER owns + * the DACLs (the sandbox seam's per-session grant reuse): init()/dispose() + * skip grant/revoke entirely and the caller must not revoke under live + * children. * @module @deepseek-ai/dsh-sandbox-windows-acl */ @@ -36,6 +39,7 @@ import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProce import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' +export { AclWriteGrant } from './grant.ts' export { Win32Error } from './errors.ts' /** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ @@ -50,6 +54,21 @@ export interface AclSandboxOptions { tempDir?: string | null /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ writeSid?: string + /** + * The file-effect mode this instance confines under — selects the + * restricted token's restricting-SID list (I for read-only, J for + * workspace-write) and MUST match the grant shape: read-only pairs with + * zero grants. The runner validates the argv-borne mode string at its + * boundary; this typed seam trusts the union. + */ + mode: 'read-only' | 'workspace-write' + /** + * Whether this instance owns its DACL grants (default true). False means + * the CALLER has already materialized the ACEs (the sandbox seam's + * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * the caller holds the grants for its own lifetime and revokes them. + */ + manageDacls?: boolean } /** Per-spawn options: the program, its argv/cwd, and the stdio shape. */ @@ -84,7 +103,10 @@ export interface AclSandboxChild { wait(): Promise } -function randomWriteSid(): string { +/** Mint a fresh orphan write SID (`S-1-4-x-y`; the subauthorities are 30-bit). + * @returns the SDDL string form. + */ +export function randomWriteSid(): string { return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` } @@ -92,14 +114,18 @@ function randomWriteSid(): string { * One write-restricted sandbox instance: token + orphan-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes whatever was granted * and throws; `dispose()` revokes all grants and reports every cleanup - * failure. + * failure. With `manageDacls: false` the caller owns the grants (per-session + * reuse): init() applies none and dispose() revokes none. */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] /** The orphan SID string whose ACEs form the write allowlist. */ readonly writeSid: string + /** The file-effect mode — the restricted token's restricting-SID list selection. */ + readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined + private readonly manageDacls: boolean private tempDirResolved: string | null | undefined private api: Win32Bindings | undefined private token: NativePtr | undefined @@ -107,6 +133,8 @@ export class AclSandbox { private grantedPaths: string[] = [] constructor(options: AclSandboxOptions) { + this.mode = options.mode + this.manageDacls = options.manageDacls ?? true this.writableDirs = options.writableDirs.map((directory) => { const absolute = resolve(directory) if (!existsSync(absolute) || !statSync(absolute).isDirectory()) { @@ -149,9 +177,14 @@ export class AclSandbox { this.tempDirResolved = tempDir } - for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - grantWrite(api, path, writeSidPtr) - this.grantedPaths.push(path) + // manageDacls: false — the caller (the sandbox seam's per-session grant) + // already materialized the ACEs; this instance must neither add nor + // remove any (its dispose() must not revoke the caller's standing grant). + if (this.manageDacls) { + for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { + grantWrite(api, path, writeSidPtr) + this.grantedPaths.push(path) + } } const logonSid = findLogonSid(api, currentToken) const restricted = createRestrictedToken( @@ -159,9 +192,8 @@ export class AclSandbox { { world: makeWellKnownSid(api, abi.WinWorldSid), authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), - interactive: makeWellKnownSid(api, abi.WinInteractiveSid), - local: makeWellKnownSid(api, abi.WinLocalSid), }, + this.mode, ) this.token = restricted if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') @@ -248,11 +280,13 @@ export class AclSandbox { const failures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (error) { - failures.push(error) + if (this.manageDacls) { + for (const path of this.grantedPaths) { + try { + revokeWrite(api, path, writeSidPtr) + } catch (error) { + failures.push(error) + } } } try { diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 578a615f84..a6be67fc3f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -8,13 +8,30 @@ * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , - * '--mode', , '--', ] + * '--mode', , + * ['--write-sid', ], '--', ] * * Modes: * - workspace-write: the workspace and temp directories carry the orphan-SID * Write grant; every other write is denied by the token intersection. * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); documented in README. + * NUL device (`> $null` fails with access denied); the token's restricting + * list also drops Authenticated Users (CIM unavailable — documented in + * README). + * + * `--write-sid`: the seam's per-session grant contract — the CALLER has + * already materialized the orphan-SID ACEs (once per session, server + * lifetime) and owns their revocation, so the runner neither grants nor + * revokes (manageDacls: false). Absent `--write-sid` (standalone/test use) + * the runner self-manages grants per invocation as before. With + * `--write-sid` in workspace-write mode, the runner rewrites the TMP/TEMP + * entries of its OWN environment (SetEnvironmentVariableW) to the `--temp` + * directory — a PRIVATE per-session temp subdirectory the seam provisions + * (bwrap `--tmpfs /tmp` semantics) — and the child inherits the rewritten + * block (lpEnvironment NULL; an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW, verified empirically). + * Read-only leaves the ambient temp entries untouched (writes there are + * denied anyway). * * Failure contract: every runner-side failure (bad args, missing * directories, token/grant/spawn errors) prints `windows-acl-run: ` @@ -43,6 +60,7 @@ interface ParsedArgs { workspace: string temp: string mode: 'read-only' | 'workspace-write' + writeSid: string | undefined command: string args: string[] } @@ -51,6 +69,7 @@ function parseArgs(raw: string[]): ParsedArgs { let workspace: string | undefined let temp: string | undefined let mode: string | undefined + let writeSid: string | undefined let index = 0 for (; index < raw.length; index++) { const token = raw[index] @@ -65,6 +84,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--workspace': workspace = value; break case '--temp': temp = value; break case '--mode': mode = value; break + case '--write-sid': writeSid = value; break default: fail(`unknown argument: ${token}`) } } @@ -74,7 +94,7 @@ function parseArgs(raw: string[]): ParsedArgs { const argv = raw.slice(index) const command = argv[0] if (command === undefined) fail('missing command after --') - return { workspace, temp, mode, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -101,9 +121,28 @@ async function main(): Promise { const sandbox = new AclSandbox({ writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, + mode: parsed.mode, + ...parsed.writeSid === undefined ? {} : { writeSid: parsed.writeSid }, + // With --write-sid the seam owns the DACLs (per-session grants): this + // invocation must neither add nor revoke ACEs. + manageDacls: parsed.writeSid === undefined, }) await sandbox.init() + // The seam's per-session temp contract: under --write-sid, workspace-write + // children see the PRIVATE per-session temp subdirectory through TMP/TEMP + // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment + // (SetEnvironmentVariableW) and the child inherits the block; self-managed + // and read-only runs keep the ambient entries. + if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { + if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + try { const child = sandbox.spawn({ command: parsed.command, diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index 9c0436f29f..b71efe2ac7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -87,7 +87,11 @@ export interface SpawnedNative { /** * Create a process under the restricted token with piped stdio. The child's * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends - * are returned for draining. + * are returned for draining. The child inherits the caller's environment block + * (lpEnvironment NULL); the caller rewrites entries through + * SetEnvironmentVariableW before spawning (the runner's per-session temp + * contract) — passing an explicit block through koffi trips + * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically). * @param api - the binding table. * @param token - the restricted token the child runs under. * @param options - command, args, and working directory. diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 569c428551..009592bb28 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -105,22 +105,33 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { export interface RestrictingSidSet { world: NativePtr authUser: NativePtr - interactive: NativePtr - local: NativePtr } /** - * Create the write-restricted token. Ordering matters: EVERYONE first (the - * POC's note — the intersection check hits it on most objects), then the - * logon SID, Authenticated Users, INTERACTIVE, LOCAL, and finally the orphan - * write SID that forms the write allowlist. S-1-2-1 (console logon) is - * intentionally absent: see win32-abi.ts for the verified failure modes. - * FAILS CLOSED: any failure throws — never spawn unrestricted. + * Create the write-restricted token with the mode-selected restricting list + * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant + * harness): + * - list I (read-only): [logon SID, EVERYONE, orphan] + * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] + * + * The logon SID and EVERYONE are shared: they keep the early startup chain + * (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh + * crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in + * list J ONLY because the CIM path's WMI namespace security check requires it + * (0x80041003 otherwise) — read-only drops it for a zero ambient-write + * surface (it closes the host's C:\-root tree-creation escape, where + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM + * unavailability; documented in README. INTERACTIVE/LOCAL are absent from + * BOTH lists — the host's Public tree grants write to INTERACTIVE, so + * removing it closes that escape. S-1-2-1 (console logon) is intentionally + * absent: see win32-abi.ts for the verified failure modes. FAILS CLOSED: any + * failure throws — never spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. * @param writeSid - the orphan SID forming the write allowlist. * @param known - the well-known SIDs entering the restricting list. + * @param mode - selects the restricting list (I for read-only, J for workspace-write). * @returns the restricted token handle. */ export function createRestrictedToken( @@ -129,15 +140,11 @@ export function createRestrictedToken( logonSid: NativePtr, writeSid: NativePtr, known: RestrictingSidSet, + mode: 'read-only' | 'workspace-write', ): NativePtr { - const restrictingSids = buildRestrictingSids([ - known.world, - logonSid, - known.authUser, - known.interactive, - known.local, - writeSid, - ]) + const restrictingSids = buildRestrictingSids(mode === 'read-only' + ? [logonSid, known.world, writeSid] + : [logonSid, known.world, known.authUser, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 942862ceb9..b96acf4942 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -202,6 +202,15 @@ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 /** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */ export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 +// ACE_HEADER.AceType (winnt.h lines ~3449-3463) +/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */ +export const ACCESS_ALLOWED_ACE_TYPE = 0 + +// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286 +// #define SID_MAX_SUB_AUTHORITIES 15). +/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */ +export const SID_MAX_SUB_AUTHORITIES = 15 + // ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when // reading a DACL are marked with this bit and are not part of the explicit // DACL edits this module makes. diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index fe0bc13acc..2e4c5baab7 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -12,7 +12,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' @@ -148,11 +148,33 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) + it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + const api = await win32() + const dir = scratch() + const orphanSid = sidFromString(api, 'S-1-4-4242-2') + const apply = vi.spyOn(api, 'setNamedSecurityInfoW') + try { + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + // The exact ACE now stands (the per-session grant surviving from a + // previous server lifetime): the second grant is a DACL read only. + grantWrite(api, dir, orphanSid) + expect(apply).toHaveBeenCalledTimes(1) + const aces = readDirectAces(api, dir) + expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + revokeWrite(api, dir, orphanSid) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) + } finally { + apply.mockRestore() + if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + } + }) + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { const api = await win32() const dir = scratch() - const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1' }) - const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2' }) + const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() sandboxA.dispose() @@ -216,7 +238,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => { const api = await win32() const dir = scratch() - const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5' }) + const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' }) try { await sandbox.init() const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5') diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts new file mode 100644 index 0000000000..fe803025b9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -0,0 +1,100 @@ +/** + * AclWriteGrant failure-path tests with stub binding tables (the + * failure-paths.spec.ts pattern): create fails closed on SID-parse failure, + * dispose aggregates revocation and SID-free failures into an + * AggregateError. Pure stubs — no real Win32 calls, so these run on every + * platform; the real-FFI round-trip lives in grant.spec.ts (win32 only). + */ + +import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import koffi from 'koffi' + +import type { NativePtr, Win32Bindings } from '../src/ffi.ts' +import { AclWriteGrant } from '../src/index.ts' + +const PVOID = koffi.pointer('void') + +/** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ +function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { + const state = { failReads: false } + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + getTempPathW: vi.fn((_length: number, buffer: Buffer) => { + const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` + buffer.write(temp, 'utf16le') + return temp.length + }), + createFileW: vi.fn(() => 7n), + lockFileEx: vi.fn(() => 1), + unlockFileEx: vi.fn(() => 1), + closeHandle: vi.fn(() => 1), + getNamedSecurityInfoW: vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + ) => { + if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails + koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(descriptor, PVOID, 0n) + return 0 + }), + setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { + koffi.encode(newAcl, PVOID, 9n) + return 0 + }), + setNamedSecurityInfoW: vi.fn(() => 0), + localFree: vi.fn(() => 0n), + getLastError: vi.fn(() => 2), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + return { api, failReads: () => { state.failReads = true } } +} + +describe('AclWriteGrant failure paths', () => { + it('create fails closed: a SID parse failure throws before anything is granted', () => { + const api = { + convertStringSidToSidW: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-abc-1', api)).toThrow(/ConvertStringSidToSidW/) + }) + + it('create fails closed: a null SID pointer is rejected', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 0n) + return 1 + }), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/null SID/) + }) + + it('dispose aggregates a failing revocation into an AggregateError (best-effort cleanup)', () => { + const { api, failReads } = grantThenFailApi() + const grant = AclWriteGrant.create('S-1-4-42-42', api) + grant.add('C:\\granted') + expect(grant.paths).toEqual(['C:\\granted']) + failReads() + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) + + it('dispose aggregates a failing SID free into an AggregateError', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + const grant = AclWriteGrant.create('S-1-4-42-42', api) + expect(() =>{ grant.dispose() }).toThrow(AggregateError) + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts new file mode 100644 index 0000000000..02090e2fe9 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -0,0 +1,69 @@ +/** + * AclWriteGrant tests: the server-side per-session grant materialization — + * SID parsing fail-closed, ACE add/dispose round-trip against the REAL + * directory DACL (observed through icacls, the operator's own tool), and + * the recorded path order. Win32-only, like the other real-FFI suites. + */ + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { AclWriteGrant } from '../src/index.ts' + +const isWin32 = process.platform === 'win32' + +/** The directory DACL as icacls renders it (the operator-visible form). */ +function icaclsText(path: string): string { + const result = spawnSync('icacls', [path], { encoding: 'utf8' }) + expect(result.status, `icacls failed: ${result.stderr}`).toBe(0) + return result.stdout +} + +describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { + const scratchDirs: string[] = [] + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-grant-')) + scratchDirs.push(dir) + return dir + } + + it('create parses the SID fail-closed: a malformed SID throws before anything is granted', () => { + expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) + }) + + it('add materializes the ACE (idempotently), paths report the grant order, dispose revokes it', () => { + const dir = scratch() + const grant = AclWriteGrant.create('S-1-4-9000-77') + grant.add(dir) + expect(grant.paths).toEqual([dir]) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + // A second add over the standing exact ACE is a DACL-read no-op: the + // grant stays exactly one ACE (per-session reuse after a restart). + grant.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-77') + grant.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + }) + + it('two grants with different SIDs coexist and revoke independently', () => { + const dir = scratch() + const grantA = AclWriteGrant.create('S-1-4-9000-78') + const grantB = AclWriteGrant.create('S-1-4-9000-79') + grantA.add(dir) + grantB.add(dir) + expect(icaclsText(dir)).toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantA.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-78') + expect(icaclsText(dir)).toContain('S-1-4-9000-79') + grantB.dispose() + expect(icaclsText(dir)).not.toContain('S-1-4-9000-79') + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index e370316b79..0eb78e16f4 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -51,7 +51,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () // block, which host runtimes (vitest worker pools) may not keep in sync // with process.env — and a real-temp grant would inherit over every // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp }) + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, mode: 'workspace-write' }) await sandbox.init() }) @@ -91,7 +91,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { // A malformed SID makes ConvertStringSidToSidW fail; init must throw // before any grant is applied and never spawn unrestricted. - const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1' }) + const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 680d65b253..8ddd10c474 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import { AclWriteGrant } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -59,7 +60,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // List J carries Authenticated Users: the CIM path (WMI namespace + // security check) stays alive under workspace-write. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', @@ -70,11 +74,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: OK') expect(existsSync(escapeFile)).toBe(false) expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine', () => { + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', @@ -84,7 +89,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + // List I drops Authenticated Users: the WMI namespace security check + // fails (0x80041003) — the documented read-only CIM boundary, the + // price of the zero ambient-write surface. + "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', @@ -96,6 +105,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('NUL-WRITE: DENIED') expect(result.stdout).toContain('DOLLAR-NULL: OK') expect(result.stdout).toContain('SECRET-READ: OK') + expect(result.stdout).toContain('CIM: DENIED') expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false) }, 30_000) @@ -124,6 +134,40 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(renamedDir)).toBe(true) }, 30_000) + it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { + const writeSid = 'S-1-4-9000-99' + const privateTemp = join(isolatedTemp, 'private-subdir') + mkdirSync(privateTemp) + const grant = AclWriteGrant.create(writeSid) + grant.add(privateTemp) + try { + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, + "'TEMP-ENV: ' + $env:TEMP;", + "'TMP-ENV: ' + $env:TMP", + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + // The runner granted nothing (only the caller's private-temp grant + // stands): the workspace write is denied, the private temp write lands, + // and the child's TMP/TEMP point at the private subdirectory. + expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') + expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') + expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) + expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) + expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) + } finally { + grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index aeca1ba8d6..6170482a37 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -40,6 +40,13 @@ export interface SandboxExecutionPolicy { mode: SandboxMode /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string + /** + * Opaque identity of the calling session (the `dsh-session` SessionId in + * string form). Backends key per-session state off it (e.g. the windows-acl + * per-session write grant and private temp subdirectory); absent for + * agentless calls, which fall back to per-call backend state. + */ + sessionId?: string } /** From 43d53d339df5272bace694224d18e11e7928e4e7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:30:04 +0800 Subject: [PATCH 30/81] fix(pwsh): stamp the calling session's sandbox policy onto pwsh tool calls --- docs/module-graph.md | 7 +- packages/bash/tool-pwsh/package.json | 4 + packages/bash/tool-pwsh/src/index.ts | 27 ++++-- packages/bash/tool-pwsh/tests/tools.spec.ts | 101 +++++++++++++++++++- packages/bash/tool-pwsh/tsconfig.json | 6 ++ 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index dddf086bb0..be87d4147d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -445,6 +445,7 @@ flowchart TD pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent @@ -868,6 +869,8 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools @@ -1232,7 +1235,7 @@ flowchart TD | [`app-boot`](../packages/ui/app-boot) | `ui` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | @@ -1313,7 +1316,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 0c25317faa..625ae29c30 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -30,6 +30,8 @@ "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -46,6 +48,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index a68d5d2e35..e557e56248 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,11 +4,13 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: - * foreground and `run_in_background` execution (background handles register - * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment - * through the shared `bash-env` registry, and the bash marker/truncation - * rendering story. UI presentation mirrors the bash tool's too: a completed + * Behavior mirrors `dsh-tool-bash` call-for-call minus the escalation + * surface: foreground and `run_in_background` execution (background handles + * register with the generic `ctx.tasks` runtime), the managed `DSH_*` + * environment through the shared `bash-env` registry, the per-call sandbox + * policy resolution (the calling session's mode and cwd travel to the + * confining executor), and the bash marker/truncation rendering story. UI + * presentation mirrors the bash tool's too: a completed * foreground call is a terminal card with the parsed exit-status pill, using * the shared exit-status parse from `@deepseek-ai/dsh-bash`. * @@ -19,12 +21,14 @@ import { isAbsolute, resolve as resolvePath } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' @@ -141,6 +145,14 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const defaultMode = ctx.bash.sandboxMode + const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (defaultMode !== undefined && sandboxPolicy === undefined) { + throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing') + } + /** Resolve the complete standing policy for this call when a confining executor is mounted. */ + const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => + sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) ctx.systemPrompt.section({ name: 'tool:pwsh', @@ -224,12 +236,15 @@ export function apply(ctx: Context, config: Config = {}): void { /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ async execute(args: PwshToolArgs, exec) { validatePwshArgs(args) + // Description is display metadata; workdir defaults to the caller's session. + const standingPolicy = resolveSandboxPolicy(exec) const workdir = resolveWorkdir(args.workdir, exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv: ctx.bashEnv.collect(exec), + ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 71e3124e7e..454c244c69 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' @@ -24,6 +24,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import type { BashProcessRead } from '@deepseek-ai/dsh-bash' @@ -150,9 +151,59 @@ async function setupWithTasks(toolConfig: Partial = {}, dshHome return { ctx, bash } } +/** + * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve + * the calling session's standing policy and stamp it on the request, exactly + * like the bash tool — the per-session sandbox-policy regression surface. + */ +class ConfiningFakeBash extends BashExecutor { + requests: BashExecRequest[] = [] + + override get sandboxMode() { + return 'read-only' as const + } + + override resolve(request: BashExecRequest): BashExecSpec { + this.requests.push(request) + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(_spec: BashExecSpec): Promise { + return runResult('ok\n') + } + + override start(_spec: BashExecSpec): BashProcess { + return fakeProcess() + } +} + +/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool. */ +async function setupSandboxed(toolConfig: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(SandboxPolicyService, {}) + await ctx.plugin(ConfiningFakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as ConfiningFakeBash + return { ctx, bash } +} + /** * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + * The fake session carries an empty event log (the sandbox-policy resolver + * folds the log for mode overrides, mirroring a real session). */ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const scopeFiber = ctx.plugin(() => {}) @@ -160,7 +211,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -397,6 +448,52 @@ describe('execution through the bash seam', () => { }) }) +describe('per-call sandbox policy resolution', () => { + it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { + const { ctx, bash } = await setupSandboxed() + const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + const agent = registerFakeAgent(ctx, 'policy-session') + Object.assign(agent.session.header, { cwd: sessionCwd }) + const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) + expect(result.isError).toBe(false) + // The policy's workspace root is the session cwd canonicalized by the + // policy service (realpath + resolve), NEVER the web server's launch dir; + // the calling session's identity rides along for backend per-session state. + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(sessionCwd)), + sessionId: 'policy-session', + }) + }) + + it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => { + const { ctx, bash } = await setupSandboxed() + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(bash.requests[0]?.sandboxPolicy).toEqual({ + mode: 'read-only', + workspaceRoot: resolvePath(realpathSync.native(process.cwd())), + }) + + // The base FakeBash advertises no sandboxMode, so the tool must not stamp + // any policy (the executor defaulting stays the executor's own). + const plain = await setup() + await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy') + }) + + it('fails load when a confining executor has no shared sandbox-policy resolver', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(ConfiningFakeBash) + await expect(ctx.plugin(ToolPwsh)).rejects.toThrow( + 'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing', + ) + }) +}) + describe('background execution through the task runtime', () => { it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { const { ctx } = await setupWithTasks() diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 61b2c69448..2d383d22fa 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../support/invariants" } From 07c7e2f0bd626b4d1138e6e87714beb19d270de1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:39:25 +0800 Subject: [PATCH 31/81] fix(sandbox): revoke post-apply grant failures, free init SID allocations, and name the Windows runner in SANDBOX_UNAVAILABLE --- .../sandbox/sandbox-windows-acl/src/grant.ts | 11 +++--- .../sandbox/sandbox-windows-acl/src/index.ts | 35 ++++++++++++++++--- packages/sandbox/sandbox/README.i18n.yaml | 4 +-- packages/sandbox/sandbox/README.md | 2 +- packages/sandbox/sandbox/README.zh.md | 2 +- packages/sandbox/sandbox/src/index.ts | 5 +-- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 4a0f6f4dec..7826922f1d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -57,14 +57,17 @@ export class AclWriteGrant { /** * Grant the write ACE on one directory (idempotent: an already-standing * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose}. Callers - * treat a throw as a failed materialization and dispose the instance to - * revoke the paths granted so far. + * {@link grantWrite}) and record the path for {@link dispose}. The path is + * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after + * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an + * ungranted path is a no-op merge. Callers treat a throw as a failed + * materialization and dispose the instance to revoke the paths granted so + * far. * @param path - the directory whose DACL gains the grant. */ add(path: string): void { - grantWrite(this.api, path, this.sidPtr) this.grantedPaths.push(path) + grantWrite(this.api, path, this.sidPtr) } /** Every directory currently carrying the grant, in grant order. */ diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 1ff712f24e..07f35ed177 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -130,6 +130,8 @@ export class AclSandbox { private api: Win32Bindings | undefined private token: NativePtr | undefined private writeSidPtr: NativePtr | undefined + /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */ + private sidAllocations: NativePtr[] = [] private grantedPaths: string[] = [] constructor(options: AclSandboxOptions) { @@ -182,16 +184,24 @@ export class AclSandbox { // remove any (its dispose() must not revoke the caller's standing grant). if (this.manageDacls) { for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - grantWrite(api, path, writeSidPtr) + // Record BEFORE granting: grantWrite can throw after a successful + // apply (a LocalFree failure), and the fail-closed catch must still + // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push(path) + grantWrite(api, path, writeSidPtr) } } const logonSid = findLogonSid(api, currentToken) + this.sidAllocations.push(logonSid) + const worldSid = makeWellKnownSid(api, abi.WinWorldSid) + this.sidAllocations.push(worldSid) + const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid) + this.sidAllocations.push(authUserSid) const restricted = createRestrictedToken( api, currentToken, logonSid, writeSidPtr, { - world: makeWellKnownSid(api, abi.WinWorldSid), - authUser: makeWellKnownSid(api, abi.WinAuthenticatedUserSid), + world: worldSid, + authUser: authUserSid, }, this.mode, ) @@ -201,7 +211,8 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave standing grants behind a failed init. + // Fail-closed cleanup: never leave standing grants or SID allocations + // behind a failed init. const cleanupFailures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { @@ -213,6 +224,14 @@ export class AclSandbox { } } } + for (const sidPtr of this.sidAllocations.splice(0)) { + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } if (cleanupFailures.length > 0) { throw new AggregateError( [error, ...cleanupFailures], @@ -304,6 +323,14 @@ export class AclSandbox { failures.push(error) } } + for (const sidPtr of this.sidAllocations.splice(0)) { + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + } catch (error) { + failures.push(error) + } + } this.api = undefined this.token = undefined this.writeSidPtr = undefined diff --git a/packages/sandbox/sandbox/README.i18n.yaml b/packages/sandbox/sandbox/README.i18n.yaml index 11f2a2ed86..6f3d1ca4dd 100644 --- a/packages/sandbox/sandbox/README.i18n.yaml +++ b/packages/sandbox/sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md -README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085 -README.zh.md: adccd4421a74ef073ad3ffc3a23bccb0354d99aa +README.md: d6bf873d559fe0933e7e7ee7d966e2f27053ca05 +README.zh.md: a618fe435d0c9d862c8dab4ded390de45e1e5d74 diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 1b522b2c72..d6bf873d55 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -23,7 +23,7 @@ Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-b ##### Exact error ```markdown -sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. ``` #### Token effect diff --git a/packages/sandbox/sandbox/README.zh.md b/packages/sandbox/sandbox/README.zh.md index adccd4421a..a618fe435d 100644 --- a/packages/sandbox/sandbox/README.zh.md +++ b/packages/sandbox/sandbox/README.zh.md @@ -23,7 +23,7 @@ ##### 精确错误 ```markdown -sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. ``` #### Token 影响 diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 6170482a37..fee9164ead 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -131,8 +131,9 @@ export class SandboxUnavailableError extends HarnessError { super( `sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; ` + 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing ' - + 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement ' - + 'backend yet — or switch the consumer to danger-full-access.' + + 'kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL ' + + 'restricted-token runner can start (Windows) — otherwise switch the consumer to ' + + 'danger-full-access.' + (detail === undefined ? '' : ` Runner failure: ${detail}`), SANDBOX_UNAVAILABLE, ) From c0991f256861cbe0809a95540a8b5b830c1a4ad3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 17:58:35 +0800 Subject: [PATCH 32/81] fix(docs): point the parity note at the implemented windows-pwsh-default note and refresh catalogs after the merge-forward --- .../feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- docs/cordis-catalog/services.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 51401adfdc..156d215124 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 -2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc +2026-08-05-pwsh-ui-bash-parity.md: 91951d96b3832de7e6ec1e5207fd9794dd48e120 +2026-08-05-pwsh-ui-bash-parity.zh.md: c050c91acca632df7b37ceef363203f575c78832 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 815b448b89..592c3c9223 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 967c5a9e14..82a8dbb87e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 665cb66afa..abebea09b5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:155`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:156`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` From 644162d4df3061ac0753025e6cf417d86c40e928 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:03:15 +0800 Subject: [PATCH 33/81] fix(docs): re-record the parity note pair hashes after the EOF normalization --- .../feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 156d215124..5ad32eb099 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 91951d96b3832de7e6ec1e5207fd9794dd48e120 -2026-08-05-pwsh-ui-bash-parity.zh.md: c050c91acca632df7b37ceef363203f575c78832 +2026-08-05-pwsh-ui-bash-parity.md: 592c3c922308d53f1bb83ff3ea371c38b99d5be7 +2026-08-05-pwsh-ui-bash-parity.zh.md: 82a8dbb87ed60ca7528b76c6b0d66872d9edc7f2 From f1af1831efeb1106860ddc09ddd4a53f0d4b7c6c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:10:21 +0800 Subject: [PATCH 34/81] fix(docs): point the parity note at the implemented windows-pwsh-default note and re-record the pair --- .../feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md | 2 +- .../implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index 51401adfdc..5ad32eb099 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 -2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc +2026-08-05-pwsh-ui-bash-parity.md: 592c3c922308d53f1bb83ff3ea371c38b99d5be7 +2026-08-05-pwsh-ui-bash-parity.zh.md: 82a8dbb87ed60ca7528b76c6b0d66872d9edc7f2 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 815b448b89..592c3c9223 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 967c5a9e14..82a8dbb87e 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision From e9b1c64a38b4dca04575300ecf1a451ba43b9900 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:16:19 +0800 Subject: [PATCH 35/81] fix(sandbox): update the missing-runner snapshot for the Windows-aware SANDBOX_UNAVAILABLE text --- .../tests/snapshots/missing-sandbox-runner/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl index f9e30bb24d..de0171618e 100644 --- a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785916902468,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785916902468,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"d588acd6-d0ab-43c5-9e18-67fe3f625e48"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785916902469,"data":{"turn":1,"step":1,"callId":"missing-runner-foreground","name":"bash","arguments":"{\"command\":\"true\",\"description\":\"Exercise missing sandbox runner\"}"}} -{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785916902487,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"missing-runner-foreground"},"content":[{"type":"tool-result","toolCallId":"missing-runner-foreground","content":[{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}],"isError":true}],"role":"user","id":"f7345e02-407b-483f-be7a-75a4fc1c37a7"},"error":{"name":"SandboxUnavailableError","code":"SANDBOX_UNAVAILABLE"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785916902487,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785916902496,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1785304900018,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 69fd67bfd826e143449cd778926e39de7584a99f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 18:25:48 +0800 Subject: [PATCH 36/81] fix(docs): restore the cli reference pairing record to the multi-line contract after the merge-forward --- apps/cli/reference/README.i18n.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 722725bbdf..f11f4de700 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -1 +1,6 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each# side as of the last confirmed-consistent state. Both languages carry equal authority;# after editing either side, bring the other along and re-record with:# pnpm run verify-translation-pairing --write apps/cli/reference/README.mdREADME.md: 2a0a0d5ef62e3e89c6cdaf04f9fd63de2f882ce1README.zh.md: 69d3f4f78c50cce35e82039bedd10506c4d0a9a1 \ No newline at end of file +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/cli/reference/README.md +README.md: 2a0a0d5ef62e3e89c6cdaf04f9fd63de2f882ce1 +README.zh.md: 69d3f4f78c50cce35e82039bedd10506c4d0a9a1 From 2dd9af6f4b0b9354c428a3d0822f76068a6d557e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 20:11:01 +0800 Subject: [PATCH 37/81] fix(sandbox): keep read-only strictly zero-grant by dropping the write-allowlist SID from list I MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session that materialized its workspace-write grant and then switched to read-only (or crashed and resumed under read-only) kept a writable workspace for the server lifetime: the standing orphan-SID ACE survived the downgrade and list I still carried the orphan, so the write-restricted pass-2 check granted it. List I is now [logon SID, EVERYONE] only — the standing ACE stays inert under read-only while the unrevoked ACE keeps the re-upgrade free (map hit, no re-propagation). Pinned by the runner's real-token mode-downgrade regression (standing ACE denied under read-only, effective again on re-upgrade), the acl-session mode-switch cycle (nothing under read-only, one materialization on upgrade, no revoke on downgrade), and ConstrainedLanguage pins in both modes. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 8 ++- .../sandbox-local/tests/acl-session.spec.ts | 62 +++++++++++++++++++ .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 3 +- .../sandbox/sandbox-windows-acl/README.zh.md | 3 +- .../sandbox/sandbox-windows-acl/src/token.ts | 23 ++++--- .../sandbox-windows-acl/tests/runner.spec.ts | 46 ++++++++++++++ 10 files changed, 143 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 7a11b5e95f..3b28349045 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: e93a066aca1a39fb177a7ae3c2f71f44724befd7 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e4d5254504d2f2318c6b7646f09156dfef4d7619 +2026-08-08-windows-acl-restricted-token-sandbox.md: 4459e121c09fe566efc9a35604df10dae223928c +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 307f39d3664e5e9f837ce630fc8c8b442bc4d821 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index e93a066aca..4459e121c0 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID, Everyone, orphan — no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed) and list J (`workspace-write` = + Authenticated Users, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID + Everyone only — no orphan and no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed, and a standing grant ACE from an earlier workspace-write period stays INERT: the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free) and list J (`workspace-write` = + Authenticated Users + orphan, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented). +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation — Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — and the ConstrainedLanguage pins in both modes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index e4d5254504..307f39d366 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 登录 SID、Everyone、孤儿——不含 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭)与 list J(`workspace-write` = + Authenticated Users,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 仅登录 SID + Everyone——不含孤儿 SID 与 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭,且先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**:pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)与 list J(`workspace-write` = + Authenticated Users + 孤儿 SID,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录)。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 ## Related diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 596665cffb..fb1a76cc9c 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -378,8 +378,12 @@ export class LocalSandboxProvider extends SandboxProvider { * session's first confined execution, reused for every later call (the map * hit is the whole call). Workspace-write grants the workspace root and * the private temp subdirectory (created here); read-only materializes - * NOTHING — its token alone restricts every write. Fail-closed: a - * half-materialized grant is revoked before the error propagates. + * NOTHING — its token alone restricts every write, and a standing grant + * from an earlier workspace-write period is KEPT through a downgrade + * (never revoked): the read-only restricted token carries no orphan SID + * (list I), so the ACE is inert there, while the map hit keeps the + * re-upgrade free of re-propagation. Fail-closed: a half-materialized + * grant is revoked before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index b39451d8ea..7ebb5ff2ed 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -150,6 +150,68 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const privateTemp = sessionTempDir('sess-switch') + scratch.push(privateTemp) + const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' } + + // Read-only first: the record still rides along (--write-sid, one + // event) but NOTHING is materialized and the ambient temp root is + // passed — the map stays empty, so the later upgrade must materialize. + const confinedRo = sandbox.confine(['true'], readOnly) + expect(confinedRo.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tmpdir(), + '--mode', 'read-only', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(0) + expect(existsSync(privateTemp)).toBe(false) + + // Upgrade: the FIRST workspace-write confine materializes the grant + // (the map was empty — read-only never wrote it) with the SAME SID + // and the private temp dir, so the upgrade path cannot dead-end. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', privateTemp, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) + expect(existsSync(privateTemp)).toBe(true) + + // Reuse: the second workspace-write call is the map hit. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(1) + + // Downgrade: the standing grant is KEPT — never revoked, never + // re-granted. The read-only restricted token's list I carries no + // orphan SID (pinned by the windows-acl runner regression), so the + // ACE is inert under read-only while the map hit keeps the + // re-upgrade free of eager propagation. + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { try { const ws = workspaceRoot() diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 875a1a441d..80a35821d4 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 1969515f6692eda059fd5e83449d68c6a4da5c4f -README.zh.md: 4c751ba6dc45bca8cd600b0313b342e37e9ebe54 +README.md: 2e3a16fa84541ffb2eb442c953777f37407e1b46 +README.zh.md: e58b70a120875ee57fe6e90376f7e36d9ce32ca3 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 1969515f66..2e3a16fa84 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -42,7 +42,7 @@ The runner creates the restricted token, spawns the wrapped argv under it with t Modes (the token's restricting-SID list follows the mode): - `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. -- `read-only` (list I = logon SID, Everyone, orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. +- `read-only` (list I = logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The orphan stays OUT of list I on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. @@ -82,3 +82,4 @@ None directly; the denial surface belongs to the tool layer. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. +- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 4c751ba6dc..e58b70a120 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -42,7 +42,7 @@ runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接 模式(令牌的 restricting SID 列表随模式而定): - `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 -- `read-only`(列表 I = 登录 SID、Everyone、孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 +- `read-only`(列表 I = 登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。孤儿 SID 有意留在列表 I **之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 `AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 @@ -82,3 +82,4 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 - **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 +- **两种受限模式都以 ConstrainedLanguage 运行 `pwsh`。** 受限令牌触发 PowerShell 的锁定检测,因此在 `read-only` 与 `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射都会以 `Cannot create type` / `Cannot invoke method`(“only core types”)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 会被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问继续工作。`pwsh` 工具描述把这一契约教给模型;`danger-full-access` 调用不受隔离、以 FullLanguage 运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 009592bb28..2b114a6567 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -111,7 +111,7 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant * harness): - * - list I (read-only): [logon SID, EVERYONE, orphan] + * - list I (read-only): [logon SID, EVERYONE] * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] * * The logon SID and EVERYONE are shared: they keep the early startup chain @@ -121,15 +121,22 @@ export interface RestrictingSidSet { * (0x80041003 otherwise) — read-only drops it for a zero ambient-write * surface (it closes the host's C:\-root tree-creation escape, where * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM - * unavailability; documented in README. INTERACTIVE/LOCAL are absent from - * BOTH lists — the host's Public tree grants write to INTERACTIVE, so - * removing it closes that escape. S-1-2-1 (console logon) is intentionally - * absent: see win32-abi.ts for the verified failure modes. FAILS CLOSED: any - * failure throws — never spawn unrestricted. + * unavailability; documented in README. List I also carries NO orphan: a + * standing grant ACE from an earlier workspace-write period (a + * `/permission` mode downgrade, or a crash-resumed session) must stay INERT + * under read-only — the WRITE_RESTRICTED pass-2 check grants only what the + * restricting list carries, so omitting the orphan keeps read-only strictly + * zero-grant even with stale ACEs standing, while the unrevoked ACE keeps + * the re-upgrade free (the seam's grant map hits it — no re-propagation). + * INTERACTIVE/LOCAL are absent from BOTH lists — the host's Public tree + * grants write to INTERACTIVE, so removing it closes that escape. S-1-2-1 + * (console logon) is intentionally absent: see win32-abi.ts for the + * verified failure modes. FAILS CLOSED: any failure throws — never spawn + * unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the orphan SID forming the write allowlist. + * @param writeSid - the orphan SID forming the write allowlist (list J only). * @param known - the well-known SIDs entering the restricting list. * @param mode - selects the restricting list (I for read-only, J for workspace-write). * @returns the restricted token handle. @@ -143,7 +150,7 @@ export function createRestrictedToken( mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' - ? [logonSid, known.world, writeSid] + ? [logonSid, known.world] : [logonSid, known.world, known.authUser, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 8ddd10c474..18af8a8ffd 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -57,6 +57,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('workspace-write: the confined child writes granted directories only', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", + // The restricted token puts pwsh into ConstrainedLanguage in BOTH modes + // (documented Known Limitation) — pinned here so a token change that + // silently restores FullLanguage is caught. + '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, @@ -70,6 +74,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -100,6 +105,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') expect(result.stdout).toContain('TARGET-WRITE: DENIED') expect(result.stdout).toContain('TEMP-WRITE: DENIED') expect(result.stdout).toContain('NUL-WRITE: DENIED') @@ -168,6 +174,46 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { + // The reported defect: a session that materialized its grant in + // workspace-write keeps the ACE standing for the server lifetime. After + // switching to read-only, the restricted token's list I must carry NO + // orphan SID — the standing ACE stays but the pass-2 check cannot use + // it, so the workspace write is denied (previously it LEAKED). The + // switch back reuses the SAME standing ACE: the re-upgrade write lands + // without any re-grant. + const writeSid = 'S-1-4-9001-7' + const grant = AclWriteGrant.create(writeSid) + grant.add(writableDir) + try { + const downgradeProbe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`, + ].join('') + const downgraded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe, + ]) + expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0) + expect(downgraded.stdout).toContain('DOWNGRADE-WRITE: DENIED') + expect(existsSync(join(writableDir, 'downgraded.txt'))).toBe(false) + + const reupgradeProbe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`, + ].join('') + const reupgraded = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe, + ]) + expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0) + expect(reupgraded.stdout).toContain('REUPGRADE-WRITE: OK') + expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true) + } finally { + grant.dispose() + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) From 83f835e4cb12be728e0140bb282bca49d2e8244b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 20:11:21 +0800 Subject: [PATCH 38/81] =?UTF-8?q?feat(pwsh):=20mirror=20the=20bash=20sandb?= =?UTF-8?q?ox=20surface=20=E2=80=94=20denial=20rendering,=20escalation,=20?= =?UTF-8?q?and=20the=20ConstrainedLanguage=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool-pwsh now renders sandbox denial facts (denial marker + same-turn escalation hint, runner-failed notice on background reads) and advertises the sandbox_permissions/justification escalation pair resolved through ctx.approval before execution — the shared fail-closed sequence from dsh-sandbox, with the tool name 'pwsh'. The description teaches the ConstrainedLanguage contract under the Windows sandbox (both confined modes run pwsh in CLM: Add-Type, non-core .NET statics, COM, and reflection fail; the mode cannot be lifted — probe-verified, pinned by LANGMODE assertions in the runner suite and description assertions in the tool suite). The stale pwsh-sandbox JSDoc ('the tool owns approval') and the parity notes' superseded 'minus sandbox' claims are corrected. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 +- .../2026-08-01-pwsh-tool-and-executor.md | 8 +- .../2026-08-01-pwsh-tool-and-executor.zh.md | 8 +- ...2026-08-02-pwsh-tool-bash-parity.i18n.yaml | 4 +- .../2026-08-02-pwsh-tool-bash-parity.md | 6 +- .../2026-08-02-pwsh-tool-bash-parity.zh.md | 6 +- docs/config-catalog.md | 4 +- docs/module-graph.md | 3 +- docs/tool-catalog.md | 2 +- packages/bash/pwsh-sandbox/src/index.ts | 15 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 16 +- packages/bash/tool-pwsh/README.zh.md | 16 +- packages/bash/tool-pwsh/package.json | 2 + packages/bash/tool-pwsh/src/index.ts | 140 ++++++++-- packages/bash/tool-pwsh/src/render.ts | 52 +++- packages/bash/tool-pwsh/tests/tools.spec.ts | 257 +++++++++++++++++- packages/bash/tool-pwsh/tsconfig.json | 6 + 18 files changed, 471 insertions(+), 82 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index efd7d56dae..45c2df39d0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: 77f0a13d0efa55c91b4e58ee2474ac6877947c80 -2026-08-01-pwsh-tool-and-executor.zh.md: 95c0d8f087f11d192350cf92ed866bb8a4ea33b4 +2026-08-01-pwsh-tool-and-executor.md: a511035d959ed873d75af87e9f56374dd4cd4f27 +2026-08-01-pwsh-tool-and-executor.zh.md: c0e6a73228e08f2b5f2d5a6ccfb9a6eecce3ba7d diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index 77f0a13d0e..a511035d95 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -6,14 +6,14 @@ English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md) ## Problem -The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry. +The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool is also larger than a Windows-first profile strictly needs — the persistent-PTY twin in particular is bash-shaped surface the `pwsh` tool still does not carry. The original minimal profile also left out background tasks and sandbox escalation: background arrived with the [parity decision](2026-08-02-pwsh-tool-bash-parity.md), and the sandbox surface (denial rendering plus `sandbox_permissions` escalation) arrived with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the minimal tool was sized for the danger-full-access Windows posture, and that premise ended when the sandbox PR re-enabled confinement and approval on Windows. ## Decision Two new packages under `packages/bash/`: - **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. -- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus a Windows-specific ConstrainedLanguage contract in the tool description. The parity decision supersedes this note's minimal-profile tool description. Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. @@ -23,13 +23,13 @@ The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash of **Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation. -**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest. +**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the model-visible contract is the dialect itself (paths, variables, exit facts differ), so a dialect parameter would either churn the schema conditionally or force one tool to teach two dialects; the separate twin keeps the model contract honest — and carries the shared surfaces (background, sandbox, rendering) by mirroring rather than by sharing an implementation. **Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default. ## Consequences - The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. -- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage boundary precisely. - Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. - The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index 95c0d8f087..c0e6a73228 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -6,14 +6,14 @@ Status: implemented ## 问题 -harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。 +harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具也大于 Windows 优先画像的严格所需——尤其持久 PTY 孪生是 `pwsh` 工具至今仍不背负的 bash 形状表面。最初的最小画像也没有后台任务与沙箱升级:后台随 [parity 决策](2026-08-02-pwsh-tool-bash-parity.md) 到来,沙箱面(拒绝渲染加 `sandbox_permissions` 升级)随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 到来——最小工具当初按 danger-full-access 的 Windows 姿态裁剪,这一前提在 sandbox PR(Pull Request)于 Windows 上重新启用隔离与审批时终结。 ## 决策 在 `packages/bash/` 下新增两个包: - **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 -- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 契约。parity 决策取代了本 note 的最小画像工具描述。 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 @@ -23,13 +23,13 @@ Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道 **给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。 -**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。 +**给 `dsh-tool-bash` 增加方言参数。** 否决:模型可见契约本身就是方言(路径、变量、退出事实都不同),因此方言参数要么让 schema 按条件翻动,要么逼一个工具教两种方言;独立的孪生让模型契约保持诚实——并以镜像而非共享实现的方式携带共享表面(后台、沙箱、渲染)。 **现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。 ## 后果 - bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 -- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 契约、沙箱拒绝/升级词汇与 ConstrainedLanguage 边界。 - Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 - CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index 40e24e9ac2..8dd8add3bd 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade -2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a +2026-08-02-pwsh-tool-bash-parity.md: 9b1e04e3c460f8aaefa5eb34449cca57a966b600 +2026-08-02-pwsh-tool-bash-parity.zh.md: 5c9dc70f259f483a841129724f46bf5d9e758655 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index e35a903892..9b1e04e3c4 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -10,13 +10,13 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Decision -`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior: +`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, and its model-visible text describes exactly that behavior: - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. - **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. -- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. +- **Out of scope, unchanged**: persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). Sandbox escalation shipped later with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the pwsh tool now carries the sandbox denial rendering and the same-turn `sandbox_permissions` escalation surface, plus the Windows ConstrainedLanguage contract in its description. The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. ## Alternatives considered @@ -28,7 +28,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi ## Consequences -- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. +- The bash and pwsh tools are now behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface arrived with the Windows ACL sandbox decision), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes. - Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture. - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index d7a68f0cab..5c9dc70f25 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为: +`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,其模型可见文本精确描述这一行为: - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 - **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 -- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 +- **范围外,不变**:持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。sandbox 升级随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 稍后交付——pwsh 工具现在携带 sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面,外加其描述中的 Windows ConstrainedLanguage 契约。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 ## 备选方案 @@ -28,7 +28,7 @@ Status: implemented ## 后果 -- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 +- bash 与 pwsh 工具在前台、后台与沙箱化 shell 工作上行为可互换(sandbox 面随 Windows ACL sandbox 决策到来),pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。 - 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。 - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a4ac5b09ad..411253cfd9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1138,7 +1138,7 @@ export type Config = LocalConfig Depends on: [`LocalConfig`](#deepseek-aidsh-pwsh-local) -Source: [`packages/bash/pwsh-sandbox/src/index.ts:39`](../packages/bash/pwsh-sandbox/src/index.ts) +Source: [`packages/bash/pwsh-sandbox/src/index.ts:40`](../packages/bash/pwsh-sandbox/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -2057,7 +2057,7 @@ export interface Config { } ``` -Source: [`packages/bash/tool-pwsh/src/index.ts:47`](../packages/bash/tool-pwsh/src/index.ts) +Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/module-graph.md b/docs/module-graph.md index 5a0447e62e..477399d72d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -988,6 +988,7 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1342,7 +1343,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 42b68bb6e8..02170c580e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -212,7 +212,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `pwsh` -Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. +Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. ```json { diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts index 38e44a431a..7a930b7f0e 100644 --- a/packages/bash/pwsh-sandbox/src/index.ts +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -6,8 +6,9 @@ * enforcement, and denial facts. Positive runner-launch evidence means the * command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while * background processes carry `runnerFailed`; other spawn rejections retain - * local-executor semantics. The tool owns approval and passes a complete - * per-call policy. + * local-executor semantics. The tool layer owns the escalation approval flow + * through `ctx.approval`; this executor reports the sandbox facts the tool + * renders. * @module @deepseek-ai/dsh-pwsh-sandbox */ @@ -40,10 +41,12 @@ export type Config = LocalConfig /** * Registers as `ctx.bash` in place of the local pwsh executor and requires a - * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is - * unchanged. Tool calls pass the calling session's resolved policy; direct - * calls fall back to deployment policy. `result.sandbox` reports the mode and - * enforcement actually used. + * `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the + * sandbox denial rendering and escalation surface (see the + * pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's + * resolved policy; direct calls fall back to deployment policy. + * `result.sandbox` reports the mode, enforcement, and denial facts the tool + * renders. */ /* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */ export class SandboxPwshExecutor extends PwshLocalExecutor { diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 39325f5987..fa9af8edce 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md -README.md: 78eb161f77b9524bc577b273abe59db6b931727c -README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210 +README.md: c0c253fe24bd7eaa4f4d814141e17a67f10f4fb8 +README.zh.md: 7e672bfb4fbda8f683cab3dbb018624d43b2a6d2 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 78eb161f77..c0c253fe24 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, the sandbox denial rendering with the same-turn `sandbox_permissions` escalation surface, and the bash marker/truncation rendering story (a clean exit produces no marker). Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). @@ -21,6 +21,8 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero | `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | | `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | | `run_in_background` | boolean | Return a task id immediately; no timeout applies. | +| `sandbox_permissions` | string enum | Advertised only when a sandboxing executor is mounted (`ctx.bash.sandboxMode` defined). The wider sandbox mode for a one-shot retry of a command the sandbox just denied — the narrowest wider mode that suffices, requiring `justification` and user approval through `ctx.approval` BEFORE execution. A non-widening or unapprovable request fails closed without running anything. | +| `justification` | string | Required with `sandbox_permissions`: one sentence for the user explaining why this exact command needs the wider access. | `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. @@ -28,9 +30,9 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. -Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, sandbox-denial (with the same-turn escalation hint when the composition advertises escalation), timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. -The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. @@ -78,7 +80,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict #### What the model sees -The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[sandbox: file access denied under mode]` plus the escalation hint `[sandbox: escalation available — …]` (only when the composition advertises escalation), `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. #### Token effect @@ -106,7 +108,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, the shared escalation failures (not strictly wider / no approval service / no agent to route / no approval channel / user rejected / was cancelled), `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. #### Token effect @@ -118,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). +- **ConstrainedLanguage under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The tool description teaches this contract to the model; the backend README owns the full limitation. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. -- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. +- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 17696fe6d9..7e672bfb4f 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 @@ -21,6 +21,8 @@ | `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | | `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | | `run_in_background` | boolean | 立即返回 task id;不适用超时。 | +| `sandbox_permissions` | string enum | 仅当已挂载 sandbox 执行器时才会公开(`ctx.bash.sandboxMode` 已定义)。用于对刚被 sandbox 拒绝的命令做一次性重试的更宽 sandbox 模式——取刚好足够的最窄更宽模式,要求 `justification` 并在执行**之前**经 `ctx.approval` 获得用户批准。未拓宽或无法获批的请求 fail-closed,不运行任何内容。 | +| `justification` | string | 必须与 `sandbox_permissions` 一同提供:用一句话向用户解释为何正是这条命令需要更宽的访问。 | `command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 @@ -28,9 +30,9 @@ 每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 -结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、sandbox 拒绝(组合公开升级能力时带同轮次升级提示)、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 -规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 @@ -78,7 +80,7 @@ Non-zero exits are reported as `[exit code: N]` markers; investigate failures be #### What the model sees -渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[sandbox: file access denied under mode]` 加升级提示 `[sandbox: escalation available — …]`(仅当组合公开升级能力时)、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 #### Token effect @@ -106,7 +108,7 @@ ack 是固定短行;任务输出按读取有界。 #### What the model sees -校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 +校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`invalid escalation: sandbox_permissions requires a justification`、`invalid escalation: justification is only valid together with sandbox_permissions`、`invalid justification: expected a non-empty sentence`、`sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`、共享的升级失败(非严格更宽、无审批服务、无 agent 可路由、无审批通道、用户拒绝、已取消)、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 #### Token effect @@ -118,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 +- **Windows sandbox 下的 ConstrainedLanguage** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。工具描述把这一契约教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 -- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 +- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 625ae29c30..042166b493 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index e557e56248..e93b246469 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,15 +4,17 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Behavior mirrors `dsh-tool-bash` call-for-call minus the escalation - * surface: foreground and `run_in_background` execution (background handles - * register with the generic `ctx.tasks` runtime), the managed `DSH_*` - * environment through the shared `bash-env` registry, the per-call sandbox - * policy resolution (the calling session's mode and cwd travel to the - * confining executor), and the bash marker/truncation rendering story. UI - * presentation mirrors the bash tool's too: a completed - * foreground call is a terminal card with the parsed exit-status pill, using - * the shared exit-status parse from `@deepseek-ai/dsh-bash`. + * Behavior mirrors `dsh-tool-bash` call-for-call: foreground and + * `run_in_background` execution (background handles register with the + * generic `ctx.tasks` runtime), the managed `DSH_*` environment through the + * shared `bash-env` registry, the per-call sandbox policy resolution (the + * calling session's mode and cwd travel to the confining executor), the + * sandbox-denial rendering with the same-turn escalation surface + * (`sandbox_permissions` + `justification` resolved through + * `ctx.approval`), and the bash marker/truncation rendering story. UI + * presentation mirrors the bash tool's too: a completed foreground call is + * a terminal card with the parsed exit-status pill, using the shared + * exit-status parse from `@deepseek-ai/dsh-bash`. * * @module @deepseek-ai/dsh-tool-pwsh */ @@ -27,12 +29,15 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' -import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-user-approval' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import type { BashRunResult } from '@deepseek-ai/dsh-bash' import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' +import type { RenderablePwshResult } from './render.ts' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -61,6 +66,8 @@ interface PwshToolArgs { timeoutMs?: number workdir?: string run_in_background?: boolean + sandbox_permissions?: string + justification?: string } /** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ @@ -73,6 +80,7 @@ interface PwshForegroundResult { timeoutMs: number stdout: { text: string; truncated: boolean; spillPath?: string } stderr: { text: string; truncated: boolean; spillPath?: string } + sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean } } /* jscpd:ignore-start -- minimal mirror of dsh-tool-bash's validation and execute plumbing (Agent Note). */ @@ -86,21 +94,41 @@ function validatePwshArgs(args: PwshToolArgs): void { if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`) } + // The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is + // the shared rule both enforcing families validate identically. + validateEscalationArgs(args.sandbox_permissions, args.justification) } /* jscpd:ignore-end */ -function pwshDescription(backgroundEnabled: boolean): string { +function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string { const background = backgroundEnabled ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.' : 'Background execution is not available; long-running commands must finish within the timeout.' - return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + const base = 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. ' + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background + if (escalationModes.length === 0) return base + return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + + 'Attempting a command the sandbox may deny is safe and expected: run it and read the ' + + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it ' + + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry ' + + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) ' + + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the ' + + 'approval prompt raised by that retry is how the user consents. If the session states approval ' + + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. ' + + 'Never escalate speculatively: ground the request in a real denial — normally the one this command ' + + 'just hit; escalating up front is fine only when this session already denied the same access. ' + + 'A rejected escalation is final for that command — stop and explain, never work around ' + + 'it — but it does not forbid attempting or escalating other commands later.' } /** @@ -133,6 +161,14 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ stdout: output(result.stdout), stderr: output(result.stderr), + ...result.sandbox !== undefined ? { + sandbox: { + mode: result.sandbox.mode, + denied: result.sandbox.denied, + ...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {}, + ...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {}, + }, + } : {}, } } @@ -143,17 +179,56 @@ const BACKGROUND_OUTPUT_PROPERTIES = { } as const /* jscpd:ignore-end */ +/* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true const defaultMode = ctx.bash.sandboxMode + const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') if (defaultMode !== undefined && sandboxPolicy === undefined) { throw new Error('tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing') } + /* jscpd:ignore-end */ /** Resolve the complete standing policy for this call when a confining executor is mounted. */ const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined => sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) + /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's escalation resolver (pwsh-tool-and-executor Agent Note). */ + /** + * Resolve a sandbox-escalation request through `ctx.approval` BEFORE + * anything executes, delegating the shared fail-closed sequence (strict + * widening, channel resolution, outcome mapping) to + * {@link approveEscalation}. This tool contributes only the composition + * guard (the fields are unadvertised without a sandboxing executor, yet + * schema validation checks advertised keys only, so an unadvertised + * `sandbox_permissions` still reaches execute) and the approval + * ingredients. The shared policy resolver is required whenever the + * executor advertises confinement, so a split composition fails at + * tool-plugin load. + */ + const approvePwshEscalation = ( + mode: string, + justification: string, + exec: ToolExecution, + standingPolicy: SandboxExecutionPolicy | undefined, + ): Promise => { + if (escalationModes.length === 0) { + throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') + } + const effectiveMode = (standingPolicy as SandboxExecutionPolicy).mode + return approveEscalation( + { requestedMode: mode, justification, effectiveMode, subject: 'command' }, + { + approver: ctx.get('approval'), + agent: exec.agent, + callId: exec.callId, + toolName: 'pwsh', + signal: exec.signal, + }, + ) + } + /* jscpd:ignore-end */ + ctx.systemPrompt.section({ name: 'tool:pwsh', order: 105, @@ -163,7 +238,8 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.tools.register(defineTool({ name: 'pwsh', - description: pwshDescription(backgroundEnabled), + description: pwshDescription(backgroundEnabled, escalationModes), + /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's parameter surface (pwsh-tool-and-executor Agent Note). */ parameters: { command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, description: { @@ -178,7 +254,19 @@ export function apply(ctx: Context, config: Config = {}): void { ...backgroundEnabled ? { run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, } : {}, + ...escalationModes.length > 0 ? { + sandbox_permissions: { + type: 'string' as const, + enum: [...escalationModes], + description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.', + }, + justification: { + type: 'string' as const, + description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.', + }, + } : {}, }, + /* jscpd:ignore-end */ output: { // The foreground result wire shape mirrors dsh-tool-bash's by contract — // consumers of one must accept the other (see the pwsh-tool-and-executor @@ -221,6 +309,16 @@ export function apply(ctx: Context, config: Config = {}): void { spillPath: { type: 'string' }, }, }, + sandbox: { + type: 'object', + additionalProperties: false, + properties: { + mode: { type: 'string', required: true }, + denied: { type: 'boolean', required: true }, + enforcement: { type: 'string' }, + runnerFailed: { type: 'boolean' }, + }, + }, }, }, ], @@ -230,7 +328,7 @@ export function apply(ctx: Context, config: Config = {}): void { type: 'text', text: value.kind === 'background' ? `started background task ${value.taskId}` - : renderPwshResult(value), + : renderPwshResult(value as RenderablePwshResult, escalationModes), }], }, /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ @@ -238,13 +336,19 @@ export function apply(ctx: Context, config: Config = {}): void { validatePwshArgs(args) // Description is display metadata; workdir defaults to the caller's session. const standingPolicy = resolveSandboxPolicy(exec) + const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined + ? await approvePwshEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy) + : undefined + const policy = approvedMode === undefined + ? standingPolicy + : { ...(standingPolicy as SandboxExecutionPolicy), mode: approvedMode } const workdir = resolveWorkdir(args.workdir, exec) const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, dshEnv: ctx.bashEnv.collect(exec), - ...standingPolicy !== undefined ? { sandboxPolicy: standingPolicy } : {}, + ...policy !== undefined ? { sandboxPolicy: policy } : {}, } if (args.run_in_background === true) { // Undeclared keys are allowed, so schema omission also needs enforcement. @@ -256,15 +360,11 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } // The caller owns cancellation until ctx.tasks commits detached ownership. - /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; - pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts - already-aborted signals first, so this mirror-only guard has no reachable trigger. */ if (exec.signal.aborted) { const error = new HarnessError('tool call aborted', TOOL_ABORTED) error.name = 'AbortError' throw error } - /* v8 ignore end */ // Task preflight finishes before the starter can spawn a process. const id = tasks.start({ kind: 'pwsh', @@ -275,7 +375,7 @@ export function apply(ctx: Context, config: Config = {}): void { return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), - readOutput: () => renderPwshProcessRead(proc.readOutput()), + readOutput: () => renderPwshProcessRead(proc.readOutput(), proc.sandbox, escalationModes), } }, }) diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts index 42f4bc696c..52616a8e68 100644 --- a/packages/bash/tool-pwsh/src/render.ts +++ b/packages/bash/tool-pwsh/src/render.ts @@ -1,17 +1,20 @@ /** * Model-facing result rendering for the pwsh tool — the PowerShell twin of - * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked - * stderr section, truncation notices with spill paths, then exit-status - * markers. Non-zero exits are reported, not errored — the model decides how to - * react; only infrastructure failures (spawn errors, aborts) surface as - * isError results. + * `dsh-tool-bash`'s renderer: stdout, a marked stderr section, sandbox + * denial/runner-failure markers (with the same-turn escalation hint), and + * truncation notices with spill paths, then exit-status markers. Non-zero + * exits are reported, not errored — the model decides how to react; only + * infrastructure failures (spawn errors, aborts) surface as isError + * results. * * @module @deepseek-ai/dsh-tool-pwsh/render */ -import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { BashProcessRead, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' -/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts (Agent Note). */ /** Append the truncation notice (with the full-output spill path) to a stream's text. */ function streamText(output: CollectedOutput): string { @@ -27,6 +30,7 @@ export interface RenderablePwshResult { timeoutMs: number stdout: CollectedOutput stderr: CollectedOutput + sandbox?: BashSandboxInfo } /** @@ -34,9 +38,15 @@ export interface RenderablePwshResult { * stderr section, then exit-status markers, matching the bash tool's story — * a clean exit (0, no signal) produces no marker. * @param result - the completed foreground run from the executor. + * @param escalationModes - the escalation targets this composition advertises; + * non-empty adds the same-turn escalation hint after a denial marker + * (default `[]`: no hint). * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. */ -export function renderPwshResult(result: RenderablePwshResult): string { +export function renderPwshResult( + result: RenderablePwshResult, + escalationModes: readonly SandboxMode[] = [], +): string { const out = streamText(result.stdout) const err = streamText(result.stderr) @@ -49,6 +59,14 @@ export function renderPwshResult(result: RenderablePwshResult): string { if (body.length === 0) body = '(no output)' const markers: string[] = [] + // Keep the exit marker last because parseExitStatus anchors there. + if (result.sandbox?.denied) { + markers.push(sandboxDenialMarker(result.sandbox.mode)) + // Hint only when the composition exposes escalation, before the final exit marker. + if (escalationModes.length > 0) { + markers.push(escalationHintMarker('command')) + } + } // A command may trap the termination and exit 0 after timeout; still report interruption. if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) if (result.signal !== null) { @@ -67,14 +85,28 @@ export function renderPwshResult(result: RenderablePwshResult): string { * sees: the incremental delta, plus the lossy-read notice (with full-stream * spill paths) when in-memory truncation dropped unread bytes. * @param read - one incremental read from the process handle. - * @returns the delta text with any loss notice appended. + * @param sandbox - settled sandbox facts, when this was a confined process. + * @param escalationModes - escalation targets advertised by this composition. + * @returns the delta text with any loss or sandbox notice appended. */ -export function renderPwshProcessRead(read: BashProcessRead): string { +export function renderPwshProcessRead( + read: BashProcessRead, + sandbox?: BashSandboxInfo, + escalationModes: readonly SandboxMode[] = [], +): string { const notices: string[] = [] if (read.lossy) { const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) } + if (sandbox?.runnerFailed) { + notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`) + } else if (sandbox?.denied) { + notices.push(sandboxDenialMarker(sandbox.mode)) + if (escalationModes.length > 0) { + notices.push(escalationHintMarker('command')) + } + } if (notices.length === 0) return read.delta return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` } diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 454c244c69..1464ca14d3 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -5,11 +5,12 @@ * text, truncation, timeout, abort, nonzero exits, background handles — so * these tests verify the schema, argument validation, workdir derivation, * managed `DSH_*` collection, abort translation, canonical result projection, - * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * sandbox denial rendering with the escalation surface, rendering, + * background task wiring, and the UI presenters. Real-pwsh behavior * is pinned separately in integration.spec.ts. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -22,6 +23,8 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -155,9 +158,12 @@ async function setupWithTasks(toolConfig: Partial = {}, dshHome * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve * the calling session's standing policy and stamp it on the request, exactly * like the bash tool — the per-session sandbox-policy regression surface. + * Records each confined mode and returns scriptable sandbox facts so the + * escalation and rendering surfaces are testable without a real backend. */ class ConfiningFakeBash extends BashExecutor { requests: BashExecRequest[] = [] + modes: Array = [] override get sandboxMode() { return 'read-only' as const @@ -176,29 +182,73 @@ class ConfiningFakeBash extends BashExecutor { } } - override async run(_spec: BashExecSpec): Promise { - return runResult('ok\n') + override async run(spec: BashExecSpec): Promise { + this.modes.push(spec.sandboxPolicy?.mode) + return runResult('ok\n', { + sandbox: { + mode: spec.sandboxPolicy?.mode ?? 'read-only', + denied: false, + ...spec.command === 'without optional sandbox facts' + ? {} + : { enforcement: 'full' as const, runnerFailed: false }, + }, + }) } - override start(_spec: BashExecSpec): BashProcess { + override start(spec: BashExecSpec): BashProcess { + this.modes.push(spec.sandboxPolicy?.mode) return fakeProcess() } } -/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool. */ -async function setupSandboxed(toolConfig: Partial = {}) { +/** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */ +async function setupSandboxed(withApproval = false) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) - await ctx.plugin(ToolPwsh, toolConfig) + if (withApproval) await ctx.plugin(ApprovalService) + await ctx.plugin(ToolPwsh) const bash = ctx.bash as ConfiningFakeBash return { ctx, bash } } +/** + * Build a fake {@link Agent} whose session log carries the sandbox-policy + * mode-override event the escalation flow evaluates against, with an + * appendable log (the approval service records decisions through + * `session.append`). + */ +function sandboxAgent( + mode?: 'read-only' | 'workspace-write' | 'danger-full-access', + ctx?: Context, + onAppend?: (type: string) => void, +): Agent { + const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] + if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const id = SessionId('sandbox-session') + return { + id, + ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, + session: { + id, + header: { version: 0, id, createdAt: 0 }, + events, + append: (type: string, data: Record) => { + const event = { type, data } + events.push(event) + onAppend?.(type) + return event + }, + }, + } as unknown as Agent +} + /** * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. @@ -494,6 +544,154 @@ describe('per-call sandbox policy resolution', () => { }) }) +describe('sandbox escalation through ctx.approval', () => { + const escalate = { + command: 'Write-Output ok', + description: 'test escalation', + sandbox_permissions: 'workspace-write', + justification: 'the command needs workspace writes', + } + + it('advertises the sandbox fields, the escalation clause, and the ConstrainedLanguage contract', async () => { + const { ctx } = await setupSandboxed() + const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! + const properties = schema.parameters.properties as Record + expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) + expect(schema.description).toContain('approval prompt') + expect(schema.description).toContain('ConstrainedLanguage') + + for (const args of [ + { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' }, + { command: 'Write-Output ok', description: 'd', justification: 'why' }, + { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' }, + ]) { + expect((await call(ctx, 'pwsh', args)).isError).toBe(true) + } + }) + + it('the escalation fields and the ConstrainedLanguage clause stay out of sandbox-less compositions', async () => { + const { ctx } = await setup() + const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! + expect(schema.description).not.toContain('ConstrainedLanguage') + expect(schema.description).not.toContain('sandbox_permissions') + expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions') + }) + + it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => { + const plain = await setup() + expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition') + + const { ctx } = await setupSandboxed(true) + const prompted = vi.fn() + ctx.on('approval/request', () => { prompted(); return Promise.resolve('allowed-once') }) + const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write')) + expect(text(result)).toContain('not strictly wider') + expect(prompted).not.toHaveBeenCalled() + + const malformed = sandboxAgent() + ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + type: 'sandbox/mode', + data: { mode: 'unknown-mode' }, + }) + expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') + }) + + it('fails closed when approval cannot be routed', async () => { + const withoutService = await setupSandboxed() + expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service') + + const withService = await setupSandboxed(true) + expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route') + expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel') + }) + + it.each([ + ['rejected', 'user rejected'], + ['cancelled', 'was cancelled'], + ] as const)('maps an approval %s to its distinct failure', async (outcome, message) => { + const { ctx, bash } = await setupSandboxed(true) + ctx.on('approval/request', () => Promise.resolve(outcome)) + const result = await call(ctx, 'pwsh', escalate, sandboxAgent()) + expect(text(result)).toContain(message) + expect(bash.modes).toEqual([]) + }) + + it('runs a granted foreground or background call under the approved mode', async () => { + const { ctx, bash } = await setupSandboxed(true) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const agent = sandboxAgent(undefined, ctx) + ctx.agents.register(agent) + const foreground = await ctx.tools.execute({ + callId: CallId('sandbox-signal'), + name: 'pwsh', + arguments: escalate, + agent, + signal: new AbortController().signal, + }) + expect(foreground.isError).toBe(false) + const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent) + expect(text(background)).toBe('started background task pwsh-1') + expect(bash.modes).toEqual(['workspace-write', 'workspace-write']) + }) + + it('does not publish detached work when cancellation follows the escalation grant', async () => { + const { ctx, bash } = await setupSandboxed(true) + const controller = new AbortController() + const agent = sandboxAgent(undefined, ctx, (type) => { + if (type === 'approval/decided') controller.abort() + }) + ctx.agents.register(agent) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const start = vi.spyOn(bash, 'start') + + const result = await ctx.tools.execute({ + callId: CallId('cancelled-escalation-background'), + name: 'pwsh', + arguments: { ...escalate, run_in_background: true }, + agent, + signal: controller.signal, + }) + + expect(result.error).toEqual({ + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }) + expect(text(result)).toBe('Error: tool call aborted') + expect(start).not.toHaveBeenCalled() + }) + + it('uses the session override for ordinary calls and evaluates widening against it', async () => { + const { ctx, bash } = await setupSandboxed(true) + const agent = sandboxAgent('workspace-write') + await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent) + expect(bash.modes).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('omits sandbox facts the executor did not acquire from the canonical result', async () => { + const { ctx } = await setupSandboxed() + const result = await call(ctx, 'pwsh', { + command: 'without optional sandbox facts', + description: 'exercise optional sandbox facts', + }) + if (result.isError) throw new Error('expected foreground pwsh success') + expect(result.value).toMatchObject({ + kind: 'foreground', + sandbox: { mode: 'read-only', denied: false }, + }) + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement') + expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed') + }) + + it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => { + const { ctx } = await setupSandboxed(true) + ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome) + const result = await call(ctx, 'pwsh', escalate, sandboxAgent()) + expect(text(result)).toContain('unreachable variant in EscalationOutcome') + }) +}) + describe('background execution through the task runtime', () => { it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { const { ctx } = await setupWithTasks() @@ -738,6 +936,35 @@ describe('UI presentation', () => { }) }) +describe('renderPwshResult sandbox markers', () => { + const base = { + exitCode: 0, + signal: null, + timedOut: false, + timeoutMs: 1000, + stdout: { text: 'out\n', truncated: false }, + stderr: { text: '', truncated: false }, + } + + it('a denied run reports the denial marker before the exit marker', () => { + expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } })) + .toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]') + }) + + it('hints only when the composition advertises escalation', () => { + const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } } + expect(renderPwshResult(denied, ['workspace-write'])).toBe( + 'out\n[sandbox: file access denied under read-only mode]\n' + + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' + + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]', + ) + }) + + it('a confined run without a denial adds no sandbox marker', () => { + expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n') + }) +}) + describe('renderPwshProcessRead', () => { const base: BashProcessRead = { delta: 'out\n', lossy: false } @@ -774,6 +1001,20 @@ describe('renderPwshProcessRead', () => { expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') }) + + it('appends the runner-failed notice (denial outranked)', () => { + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true })) + .toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]') + }) + + it('appends the denial marker and hints only when escalation is advertised', () => { + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true })) + .toBe('x\n[sandbox: file access denied under read-only mode]') + expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write'])) + .toBe('x\n[sandbox: file access denied under read-only mode]\n' + + '[sandbox: escalation available — retry this exact command once with sandbox_permissions ' + + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + }) }) describe('processOutcome', () => { diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 2d383d22fa..bd874b34c7 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -38,6 +38,12 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../bash/bash-env" + }, + { + "path": "../../ui/user-approval" + }, { "path": "../../sandbox/sandbox" }, From 9d10a1888b0196a68baf84686ae754460c51b6b7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 20:24:19 +0800 Subject: [PATCH 39/81] fix(pwsh): record the tool-pwsh user-approval dependency in the lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de3d7f8b5f..5bdb9cf308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -994,6 +994,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From 441927c5268e1abd2be024d3b5bb69e3869b6f06 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 21:55:02 +0800 Subject: [PATCH 40/81] =?UTF-8?q?fix(sandbox):=20drop=20Authenticated=20Us?= =?UTF-8?q?ers=20from=20both=20restricting=20lists=20=E2=80=94=20close=20t?= =?UTF-8?q?he=20C:\\-root=20escape,=20CIM=20unavailable=20in=20every=20con?= =?UTF-8?q?fined=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace-write now runs [logon SID, Everyone, orphan]: the two lists differ only by the orphan, and the keep-alive group (logon SID + Everyone) is the single shared invariant. The WMI namespace security check fails in BOTH modes (0x80041003), so CIM/Get-ComputerInfo are unavailable everywhere — the price of closing the C:\\-root tree-creation escape (AU:(AD) + AU:(OI)(CI)(IO)(M)) in workspace-write too. The unused WinLocalSid/WinInteractiveSid/WinAuthenticatedUserSid constants and their ABI-probe prints are removed; the enforcement 'full' claim now stands on a closed NTFS surface. New regression: a C:\\Users\\Public subdirectory write is denied under BOTH modes (the ambient-writable blind spot the review flagged — INTERACTIVE is absent from both lists). FAT-class (non-ACL) targets outside the granted roots remain writable (no security descriptors to intersect) — documented as a legacy residue, warn-only, not engineered around. Docs/design note/PR body updated in both languages (list I/J terminology gone everywhere). --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox-local/tests/acl-session.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 10 ++-- .../sandbox/sandbox-windows-acl/README.zh.md | 10 ++-- .../sandbox/sandbox-windows-acl/src/index.ts | 7 +-- .../sandbox/sandbox-windows-acl/src/runner.ts | 9 ++- .../sandbox/sandbox-windows-acl/src/token.ts | 53 +++++++++--------- .../sandbox-windows-acl/src/win32-abi.ts | 14 +---- .../sandbox-windows-acl/tests/runner.spec.ts | 55 ++++++++++++++++--- .../sandbox-windows-acl/verify/abi-probe.cpp | 3 - 14 files changed, 104 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 3b28349045..8b534a3267 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 4459e121c09fe566efc9a35604df10dae223928c -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 307f39d3664e5e9f837ce630fc8c8b442bc4d821 +2026-08-08-windows-acl-restricted-token-sandbox.md: 62b9613adf45c8be25464118d7440954ac3e90ee +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 95c2604d6ac8f8f6edce524477f4a49cefef79d3 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 4459e121c0..62b9613adf 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is dual-mode: list I (`read-only` = logon SID + Everyone only — no orphan and no Authenticated Users, so CIM is unavailable but the ambient AU-writable surface, notably the C:\-root tree-creation escape, is closed, and a standing grant ACE from an earlier workspace-write period stays INERT: the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free) and list J (`workspace-write` = + Authenticated Users + orphan, keeping the CIM path alive at the cost of that residual surface); the verified keep-alive invariants are logon SID + Everyone for early DLL init and CNG, and Authenticated Users for the WMI namespace security check alone. Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; `read-only` loses CIM (AuthUsers dropped — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results), while `workspace-write` retains the Authenticated-Users residual (a C:\-root tree-creation escape) as the price of a working CIM path; `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, dual-mode CIM probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — and the ConstrainedLanguage pins in both modes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 307f39d366..95c2604d6a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 为双模式:list I(`read-only` = 仅登录 SID + Everyone——不含孤儿 SID 与 Authenticated Users,因此 CIM 不可用,但环境 AU 可写面(尤其是 C:\-root 建树逃逸)被关闭,且先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**:pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)与 list J(`workspace-write` = + Authenticated Users + 孤儿 SID,以保留该残余面为代价维持 CIM 通路存活);经验证的保活不变式是登录 SID + Everyone 支撑早期 DLL init 与 CNG,Authenticated Users 仅支撑 WMI namespace 安全校验。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;`read-only` 失去 CIM(AuthUsers 被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),而 `workspace-write` 保留 Authenticated-Users 残余面(C:\-root 建树逃逸)作为 CIM 通路可用的代价;`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、双模式 CIM 探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 ## Related diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index fb1a76cc9c..93b92de0a0 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -381,7 +381,7 @@ export class LocalSandboxProvider extends SandboxProvider { * NOTHING — its token alone restricts every write, and a standing grant * from an earlier workspace-write period is KEPT through a downgrade * (never revoked): the read-only restricted token carries no orphan SID - * (list I), so the ACE is inert there, while the map hit keeps the + * (the read-only list), so the ACE is inert there, while the map hit keeps the * re-upgrade free of re-propagation. Fail-closed: a half-materialized * grant is revoked before the error propagates. * @param record - the session's durable record. diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 7ebb5ff2ed..d1cca979a0 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -199,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { expect(mockState.grants).toHaveLength(1) // Downgrade: the standing grant is KEPT — never revoked, never - // re-granted. The read-only restricted token's list I carries no + // re-granted. The read-only restricted token's list carries no // orphan SID (pinned by the windows-acl runner regression), so the // ACE is inert under read-only while the map hit keeps the // re-upgrade free of eager propagation. diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 80a35821d4..834cebbe3f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 2e3a16fa84541ffb2eb442c953777f37407e1b46 -README.zh.md: e58b70a120875ee57fe6e90376f7e36d9ce32ca3 +README.md: e9309caa874a33d3df32cc23b50c0d6375ee3066 +README.zh.md: eabf8f1c1e7e985960beee5abb540a33d052a05e diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 2e3a16fa84..e9309caa87 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -40,9 +40,11 @@ The runner creates the restricted token, spawns the wrapped argv under it with t **Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. -Modes (the token's restricting-SID list follows the mode): -- `workspace-write` (list J = logon SID, Everyone, Authenticated Users, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. Authenticated Users stays in the list so the CIM path keeps working (`Get-CimInstance`, `Get-ComputerInfo`); the price is the residual Authenticated-Users-writable surface — notably the C:\ drive root, where standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs admit an AU-confined tree-creation escape — see the design note. -- `read-only` (list I = logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable, and the token also DROPS Authenticated Users for a zero ambient-write surface (the C:\-root escape above is closed). The orphan stays OUT of list I on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). The cost is CIM unavailability: the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable — the model-facing surface documents that contract, not a prompt promise. +Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): +- `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. +- `read-only` (logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable. The orphan stays OUT of the list on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). + +Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. @@ -81,5 +83,5 @@ None directly; the denial surface belongs to the tool layer. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Wide-directory and FAT-volume warnings are deferred** — the UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented; a FAT volume simply fails the grant loudly. +- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. - **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index e58b70a120..eabf8f1c1e 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -40,9 +40,11 @@ runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接 **按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 -模式(令牌的 restricting SID 列表随模式而定): -- `workspace-write`(列表 J = 登录 SID、Everyone、Authenticated Users、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。Authenticated Users 保留在列表中,CIM 路径才能继续工作(`Get-CimInstance`、`Get-ComputerInfo`);代价是残留的 Authenticated Users 可写面——尤其是 C:\ 盘根,那里驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE 允许 AU 受限的子进程通过创建目录树逃逸——见设计笔记。 -- `read-only`(列表 I = 登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置,令牌还**去掉** Authenticated Users,让环境写入面归零(上述 C:\ 根逃逸被关闭)。孤儿 SID 有意留在列表 I **之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。代价是 CIM 不可用:WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)不可用——模型可见面文档化的是这一契约,而非提示词承诺。 +模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): +- `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`(登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置。孤儿 SID 有意留在列表**之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 + +Authenticated Users 在**两种**列表中都缺席——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)在**每一种**受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——模型可见面文档化的是这一契约,而非提示词承诺。INTERACTIVE/LOCAL 同样在**两种**列表中都缺席:宿主的 Public 树把写权限授予 INTERACTIVE,因此 Public 写入会被拒绝——由 runner 的环境可写 Public-probe 回归钉住(见设计笔记)。 `AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 @@ -81,5 +83,5 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 -- **宽目录与 FAT 卷警告留待后续** —— 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现;FAT 卷只会让授权立即报错。 +- **宽目录与 FAT 卷警告留待后续;FAT 类目标保持可写。** 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**时只会让授权立即报错(无 ACL 支持)。位于授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查会通过(Everyone 在两种列表中都存在),这类目标在**两种**受限模式下都可写。FAT 视作历史残留——不支持、不工程化应对;这一仅警告性姿态在此记录成文,而非加以缓解。 - **两种受限模式都以 ConstrainedLanguage 运行 `pwsh`。** 受限令牌触发 PowerShell 的锁定检测,因此在 `read-only` 与 `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射都会以 `Cannot create type` / `Cannot invoke method`(“only core types”)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 会被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问继续工作。`pwsh` 工具描述把这一契约教给模型;`danger-full-access` 调用不受隔离、以 FullLanguage 运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 07f35ed177..f22dadfbb0 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -195,14 +195,9 @@ export class AclSandbox { this.sidAllocations.push(logonSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(worldSid) - const authUserSid = makeWellKnownSid(api, abi.WinAuthenticatedUserSid) - this.sidAllocations.push(authUserSid) const restricted = createRestrictedToken( api, currentToken, logonSid, writeSidPtr, - { - world: worldSid, - authUser: authUserSid, - }, + { world: worldSid }, this.mode, ) this.token = restricted diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index a6be67fc3f..3487e182be 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -15,9 +15,12 @@ * - workspace-write: the workspace and temp directories carry the orphan-SID * Write grant; every other write is denied by the token intersection. * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); the token's restricting - * list also drops Authenticated Users (CIM unavailable — documented in - * README). + * NUL device (`> $null` fails with access denied); the restricting list + * carries no orphan SID, so a standing grant ACE from an earlier + * workspace-write period stays inert. BOTH modes drop Authenticated Users + * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the + * Public tree writes are denied); the two lists share the keep-alive group + * (logon SID, EVERYONE) and differ only by the orphan. * * `--write-sid`: the seam's per-session grant contract — the CALLER has * already materialized the orphan-SID ACEs (once per session, server diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 2b114a6567..bd0f71ee9f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -101,44 +101,41 @@ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { return buffer } -/** The well-known SIDs packed into every restricted token's restricting list. */ +/** The well-known SID packed into every restricted token's restricting list. */ export interface RestrictingSidSet { world: NativePtr - authUser: NativePtr } /** * Create the write-restricted token with the mode-selected restricting list - * (dual lists verified on Win11 26200, see the POC-worktree restrict-variant - * harness): - * - list I (read-only): [logon SID, EVERYONE] - * - list J (workspace-write): [logon SID, EVERYONE, Authenticated Users, orphan] + * (verified on Win11 26200, see the POC-worktree restrict-variant harness): + * - read-only: [logon SID, EVERYONE] + * - workspace-write: [logon SID, EVERYONE, orphan] * - * The logon SID and EVERYONE are shared: they keep the early startup chain - * (0xC0000142 without them) and CNG (`\Device\CNG` write trustee — pwsh - * crashes 0xE0434352 without EVERYONE) alive. Authenticated Users exists in - * list J ONLY because the CIM path's WMI namespace security check requires it - * (0x80041003 otherwise) — read-only drops it for a zero ambient-write - * surface (it closes the host's C:\-root tree-creation escape, where - * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs stand) at the cost of CIM - * unavailability; documented in README. List I also carries NO orphan: a - * standing grant ACE from an earlier workspace-write period (a - * `/permission` mode downgrade, or a crash-resumed session) must stay INERT - * under read-only — the WRITE_RESTRICTED pass-2 check grants only what the - * restricting list carries, so omitting the orphan keeps read-only strictly - * zero-grant even with stale ACEs standing, while the unrevoked ACE keeps - * the re-upgrade free (the seam's grant map hits it — no re-propagation). - * INTERACTIVE/LOCAL are absent from BOTH lists — the host's Public tree - * grants write to INTERACTIVE, so removing it closes that escape. S-1-2-1 - * (console logon) is intentionally absent: see win32-abi.ts for the - * verified failure modes. FAILS CLOSED: any failure throws — never spawn - * unrestricted. + * The logon SID + EVERYONE keep-alive group is shared by both modes: early + * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — + * pwsh crashes 0xE0434352) fails without them. The orphan SID joins ONLY + * workspace-write — read-only carries no orphan, so a standing grant ACE + * from an earlier workspace-write period (a `/permission` mode downgrade, or + * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED + * pass-2 check grants only what the restricting list carries, keeping + * read-only strictly zero-grant even with stale ACEs standing, while the + * unrevoked ACE keeps the re-upgrade free (the seam's grant map hits it — no + * re-propagation). Authenticated Users is absent from BOTH lists: the WMI + * namespace security check fails (0x80041003), so CIM is unavailable in + * every confined mode, and the C:\-root tree-creation escape (standing + * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in + * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's + * Public tree grants write to INTERACTIVE, so removing it closes that + * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts + * for the verified failure modes. FAILS CLOSED: any failure throws — never + * spawn unrestricted. * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the orphan SID forming the write allowlist (list J only). + * @param writeSid - the orphan SID forming the write allowlist (workspace-write only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (I for read-only, J for workspace-write). + * @param mode - selects the restricting list (workspace-write adds the orphan). * @returns the restricted token handle. */ export function createRestrictedToken( @@ -151,7 +148,7 @@ export function createRestrictedToken( ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : [logonSid, known.world, known.authUser, writeSid]) + : [logonSid, known.world, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index b96acf4942..cc250c2f9e 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -79,20 +79,8 @@ export const LUA_TOKEN = 0x4 export const WRITE_RESTRICTED = 0x8 // WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407) -/** WinWorldSid: S-1-1-0 (Everyone). */ +/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */ export const WinWorldSid = 1 -/** - * WinLocalSid: S-1-2-0 (LOCAL) — safe, created successfully on every init - * (it sits in every restricted token's restricting list). The - * CreateWellKnownSid ERROR_INVALID_PARAMETER failure documented in this - * module's header comment belongs to WinLocalLogonSid (S-1-2-1), NOT to - * this type. - */ -export const WinLocalSid = 2 -/** WinInteractiveSid: S-1-5-4 (INTERACTIVE). */ -export const WinInteractiveSid = 11 -/** WinAuthenticatedUserSid: S-1-5-11 (Authenticated Users). */ -export const WinAuthenticatedUserSid = 17 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) /** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 18af8a8ffd..2ce251c308 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -38,6 +38,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let isolatedTemp!: string let secretFile!: string let escapeFile!: string + // The ambient-writable probe target: a subdirectory of C:\Users\Public. + // INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public + // tree's INTERACTIVE grant must NOT satisfy the write check — the ambient + // boundary the dual-list design closes (bot-reported blind spot). The + // Public tree may be unavailable or unwritable for the test user on some + // hosts; the probe test skips itself when the directory cannot be created. + let publicProbeDir: string | undefined beforeAll(() => { scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-')) @@ -47,11 +54,17 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') + try { + publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-')) + } catch { + publicProbeDir = undefined + } }) afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }) rmSync(isolatedTemp, { recursive: true, force: true }) + if (publicProbeDir !== undefined) rmSync(publicProbeDir, { recursive: true, force: true }) }) it('workspace-write: the confined child writes granted directories only', () => { @@ -65,8 +78,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, - // List J carries Authenticated Users: the CIM path (WMI namespace - // security check) stays alive under workspace-write. + // Authenticated Users is absent from BOTH lists: the WMI namespace + // security check fails (0x80041003) — CIM is unavailable under every + // confined mode (the documented contract; the C:\-root tree-creation + // escape is closed in both as the other side of the trade). "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ @@ -79,12 +94,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout).toContain('SECRET-READ: OK') - expect(result.stdout).toContain('CIM: OK') + expect(result.stdout).toContain('CIM: DENIED') expect(existsSync(escapeFile)).toBe(false) expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => { + it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', @@ -95,9 +110,9 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, - // List I drops Authenticated Users: the WMI namespace security check - // fails (0x80041003) — the documented read-only CIM boundary, the - // price of the zero ambient-write surface. + // BOTH lists drop Authenticated Users: the WMI namespace security + // check fails (0x80041003) — the documented CIM boundary of every + // confined mode, the price of the zero ambient-write surface. "try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}", ].join('') const result = runRunner([ @@ -177,7 +192,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { // The reported defect: a session that materialized its grant in // workspace-write keeps the ACE standing for the server lifetime. After - // switching to read-only, the restricted token's list I must carry NO + // switching to read-only, the restricted token's read-only list must carry NO // orphan SID — the standing ACE stays but the pass-2 check cannot use // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands @@ -214,6 +229,30 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('ambient-writable escape regression: a C:\\Users\\Public subdirectory is denied under BOTH modes (INTERACTIVE absent from both lists)', (ctx) => { + // The Public tree grants write to INTERACTIVE; the D1-D6 matrix pinned + // that removing INTERACTIVE from the restricting lists closes the escape. + // The committed suites never probed it — this pins the ambient boundary + // end to end with the real restricted token. + if (publicProbeDir === undefined) { + ctx.skip() // Public unavailable/unwritable on this host + return + } + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `try{Set-Content -Path '${publicProbeDir}\\public-escaped.txt' -Value ok -ErrorAction Stop;'PUBLIC-WRITE: OK (ESCAPE!)'}catch{'PUBLIC-WRITE: DENIED'}`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('PUBLIC-WRITE: DENIED') + expect(existsSync(join(publicProbeDir, 'public-escaped.txt')), `mode: ${mode}`).toBe(false) + } + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 866fda7160..a74afe9d80 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -109,9 +109,6 @@ int wmain() P(WRITE_RESTRICTED); P((int)WinWorldSid); - P((int)WinLocalSid); - P((int)WinInteractiveSid); - P((int)WinAuthenticatedUserSid); P((int)WinLocalLogonSid); P((int)WinConsoleLogonSid); From 6478da61e3cceccc626ccf00832b8d64bc387c9b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 23:23:38 +0800 Subject: [PATCH 41/81] fix(sandbox): harden the per-session record and the ACL runner failure paths (review round v6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable record: bound to the owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root) — a fork's copied parent record no longer provisions the child, and a tampered record fails loud. Private temp dir: random unguessable name persisted in the record, created exclusively (pre-existing entries and reparse points fail EEXIST). Persistence: a fresh provision kicks an immediate flush (no write-behind debounce), narrowing the crash window to the flush latency — documented as the one self-healing gap. Runner-failure rules: exit-gated on 127 so a confined command that prints the signature on a non-127 exit is never misclassified. Spawn: AssignProcessToJobObject failure terminates the suspended child (no hanging orphans). SandboxExecutionPolicy.sessionId is the branded SessionId. Boundary docs: qualifying clause on the absolutist sentences, NULL-DACL Known Limitation, 'full' scoped to the supported NTFS surface, CLM gate comment. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/sandbox.i18n.yaml | 4 +- docs/core-data-structures/sandbox.md | 6 +- docs/core-data-structures/sandbox.zh.md | 6 +- docs/module-graph.md | 131 ++++---- docs/persistence-catalog.md | 10 +- .../bash/pwsh-sandbox/tests/sandbox.spec.ts | 7 + packages/bash/tool-pwsh/src/index.ts | 6 + .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../sandbox/sandbox-local/src/acl-session.ts | 92 ++++-- packages/sandbox/sandbox-local/src/index.ts | 94 ++++-- .../sandbox-local/tests/acl-session.spec.ts | 294 +++++++++++------- .../sandbox/sandbox-local/tests/local.spec.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 7 +- .../sandbox/sandbox-windows-acl/README.zh.md | 7 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 4 + .../sandbox/sandbox-windows-acl/src/index.ts | 10 +- .../sandbox/sandbox-windows-acl/src/spawn.ts | 4 + .../tests/failure-paths.spec.ts | 26 ++ .../tests/provider-chain.spec.ts | 4 +- packages/sandbox/sandbox/package.json | 2 + packages/sandbox/sandbox/src/index.ts | 7 +- packages/sandbox/sandbox/tsconfig.json | 3 + 27 files changed, 472 insertions(+), 278 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 8b534a3267..c9e3414e4d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 62b9613adf45c8be25464118d7440954ac3e90ee -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 95c2604d6ac8f8f6edce524477f4a49cefef79d3 +2026-08-08-windows-acl-restricted-token-sandbox.md: 6c358c1bdcccb8ca2260abfcc6743a0dd4b852b8 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 028b7dedf53944c4f00d2db37bd6af213d94fed0 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 62b9613adf..6c358c1bdc 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution, and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-`, TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision, one-shot materialization, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — read-only materializes nothing, the upgrade materializes once, the downgrade keeps the standing grant — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 95c2604d6a..028b7dedf5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化,并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-`,TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化——新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化;在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口——并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。记录被**绑定**到其所属会话 id 并在 fold 处校验(孤儿 SID 形态、临时路径须位于宿主临时根之内):fork 复制的父记录绝不会为子会话供给 SID,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(记录 fold/供给、一次性物化、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——read-only 不物化任何内容、升级只物化一次、降级保留驻留授权——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给 SID——写 SID 与临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index abebea09b5..ca1ea8fcaa 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1160,7 +1160,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:156`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sandboxPolicy` — `SandboxPolicyService` diff --git a/docs/core-data-structures/sandbox.i18n.yaml b/docs/core-data-structures/sandbox.i18n.yaml index dd31511e20..a759b15b74 100644 --- a/docs/core-data-structures/sandbox.i18n.yaml +++ b/docs/core-data-structures/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/sandbox.md -sandbox.md: a9a1fec080e1cf86ea63e02e062b775cd6d4d0da -sandbox.zh.md: 99505265a9c440a14cc0cfc5473823ca5514984c +sandbox.md: 75e9a0a9b1eb85a5c097f2c583a1fdf94da62305 +sandbox.zh.md: df2dae84f43d5e64bb11418012bdcbd196f028f2 diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index a9a1fec080..75e9a0a9b1 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -54,12 +54,12 @@ interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } ``` diff --git a/docs/core-data-structures/sandbox.zh.md b/docs/core-data-structures/sandbox.zh.md index 99505265a9..df2dae84f4 100644 --- a/docs/core-data-structures/sandbox.zh.md +++ b/docs/core-data-structures/sandbox.zh.md @@ -54,12 +54,12 @@ interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 477399d72d..5194a1f5b7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -413,8 +413,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -425,13 +423,6 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_agent --> pkg_type_meta - pkg_bash --> pkg_invariants - pkg_bash --> pkg_sandbox - pkg_bash --> pkg_subprocess - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_compact --> pkg_invariants @@ -500,10 +491,9 @@ flowchart TD pkg_lsp_local --> pkg_lsp pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox - pkg_sandbox_local --> pkg_session + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent @@ -524,22 +514,13 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_type_meta - pkg_bash_local --> pkg_bash - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -548,9 +529,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -597,10 +575,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -614,6 +588,10 @@ flowchart TD pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -660,21 +638,22 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_bash - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants @@ -683,6 +662,9 @@ flowchart TD pkg_compact_tool_result_prune --> pkg_llm pkg_compact_tool_result_prune --> pkg_session pkg_compact_tool_result_prune --> pkg_token_meter + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -720,6 +702,10 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -758,6 +744,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1230,11 +1231,8 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1252,19 +1250,15 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | -| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | @@ -1276,10 +1270,10 @@ flowchart TD | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1290,17 +1284,21 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1308,6 +1306,9 @@ flowchart TD | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 9b396d98b8..d64524c675 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -501,13 +501,15 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ * The session's windows-acl write identity was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such - * event is the session's record ({@link sessionAclRecord}); the - * provider appends exactly one on the session's first Windows confined - * execution. + * event owned by the session is its record ({@link sessionAclRecord}); + * the provider appends exactly one on the session's first Windows + * confined execution. */ 'sandbox/acl-session': { /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session — the binding a fork's copied event cannot satisfy. */ + sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ workspace: string /** The session's private temp subdirectory under the host temp root. */ @@ -515,7 +517,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ } ``` -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:34`](../packages/sandbox/sandbox-local/src/acl-session.ts) +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:36`](../packages/sandbox/sandbox-local/src/acl-session.ts) #### `sandbox/mode` — log-only diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index 9e4aa04546..d710801004 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -129,6 +129,13 @@ describe('helpers (pure)', () => { expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined() expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined() }) + + it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => { + const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }] + expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined() + expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules)) + .toEqual({ detail: 'windows-acl-run: missing --workspace' }) + }) }) describe('matchesSignature', () => { diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index e93b246469..fb07683c96 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,6 +114,12 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base + // The CLM contract below is Windows-restricted-token behavior, but the gate + // is 'any confining executor is mounted' (escalationModes non-empty). The + // conflation is safe today because every shipped composition pairing + // tool-pwsh with a confining executor is win32-only; a future POSIX + // pwsh-sandbox composition must gate the CLM sentence on the platform + // instead (tracked in the pwsh-tool-and-executor Agent Note). return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 286020dfcd..62f85bab96 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2413,7 +2413,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SandboxExecutionPolicy', - declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: string;\n}', + declaration: 'export interface SandboxExecutionPolicy {\n mode: SandboxMode;\n workspaceRoot: string;\n sessionId?: SessionId;\n}', }, { name: 'SandboxMode', diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts index c820694b11..5247601250 100644 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -8,18 +8,20 @@ * ({@link AclWriteGrant} materialization, revoked on dispose); the record * survives restarts so a resumed session reuses the SAME SID — re-granting * idempotently merges into (or skips) the standing ACEs instead of leaking a - * fresh dead SID's ACEs per restart. A fork gets a new session id and thus a - * fresh record; the record's workspace must match the session's immutable - * cwd (asserted by the provider). + * fresh dead SID's ACEs per restart. The record is BOUND to its owning + * session id, so a fork (which copies the parent's events, record included) + * never inherits the parent's identity — it provisions a fresh one. The + * record's payload is durable input and is validated at the fold (orphan-SID + * shape, well-formed temp path); a matching-but-tampered record fails loud. * * @module dsh-sandbox-local/acl-session */ -import { createHash } from 'node:crypto' +import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -27,13 +29,15 @@ declare module '@deepseek-ai/dsh-session' { * The session's windows-acl write identity was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such - * event is the session's record ({@link sessionAclRecord}); the - * provider appends exactly one on the session's first Windows confined - * execution. + * event owned by the session is its record ({@link sessionAclRecord}); + * the provider appends exactly one on the session's first Windows + * confined execution. */ 'sandbox/acl-session': { /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session — the binding a fork's copied event cannot satisfy. */ + sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ workspace: string /** The session's private temp subdirectory under the host temp root. */ @@ -46,48 +50,73 @@ declare module '@deepseek-ai/dsh-session' { export interface AclSessionRecord { /** The orphan write SID whose ACEs form the session's write allowlist. */ writeSid: string + /** The owning session id (binds the record against fork inheritance). */ + sessionId: SessionId /** The workspace root the record was provisioned for. */ workspace: string /** The session's private temp subdirectory. */ tempDir: string } +/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */ +const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u + /** - * The session's windows-acl record: the last `sandbox/acl-session` event in - * the log, or undefined when the session has none (never confined on - * Windows). The pure fold — resume needs no catch-up machinery because - * replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the last provisioned record, or undefined without one. + * The session's record: the last `sandbox/acl-session` event owned by it, or + * undefined (never confined / a fork). Durable-input validation: tampered + * SID or temp path fails loud. @param events/@param sessionId/@returns as + * below. + * @param events - session events (other types skipped). + * @param sessionId - owning session (fork binding). + * @returns the last owned record, or undefined without one. */ -export function sessionAclRecord(events: readonly SessionEvent[]): AclSessionRecord | undefined { +export function sessionAclRecord(events: readonly SessionEvent[], sessionId: SessionId): AclSessionRecord | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] as SessionEvent - if (event.type === 'sandbox/acl-session') return event.data + if (event.type !== 'sandbox/acl-session') continue + const data = event.data + // Fork copies the parent's record: skip non-owned records (fork mints fresh). + if (data.sessionId !== sessionId) continue + if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} ` + + '(expected the orphan shape S-1-4-x-y)', + ) + } + if (typeof data.workspace !== 'string' || data.workspace.length === 0) { + throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) + } + if (typeof data.tempDir !== 'string' || dirname(data.tempDir) !== tmpdir()) { + throw new Error( + `sandbox-local: session "${sessionId}" acl record carries a temp path outside the host temp root: ${JSON.stringify(data.tempDir)}`, + ) + } + return data } return undefined } /** - * The session's private temp subdirectory: `\dsh-`. Deterministic from the session id, so it converges - * across server restarts (the same SID re-grants the same directory) and OS - * temp hygiene may reclaim it — deliberately no GC here. - * @param sessionId - the session identity. + * The session's private temp subdirectory name: `\dsh-<16 random hex>`. + * The name is RANDOM and persisted in the record — convergence across server + * restarts comes from the record (the same SID re-grants the same directory), + * not from any derivation an attacker (who knows the session id through + * `DSH_SESSION_ID`) could predict and pre-place. The provider creates it + * exclusively and rejects reparse points; OS temp hygiene may reclaim it — + * deliberately no GC here. * @returns the private temp subdirectory path. */ -export function sessionTempDir(sessionId: string): string { - const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 12) - return join(tmpdir(), `dsh-${digest}`) +export function sessionTempDir(): string { + return join(tmpdir(), `dsh-${randomBytes(8).toString('hex')}`) } /** * Provision the record for a session that has none (its first Windows - * confined execution): a fresh write SID plus the private temp - * subdirectory, appended as exactly one log-only `sandbox/acl-session` - * event — the provision IS its event, nothing mutates record state out of - * band. Fork (new session id) provisions a fresh record; resume replays the - * stored one. + * confined execution): a fresh write SID plus the private temp subdirectory, + * appended as exactly one log-only `sandbox/acl-session` event — the + * provision IS its event, nothing mutates record state out of band. Fork + * (whose copied parent record is not its own) provisions a fresh record; + * resume replays the stored one. * @param session - the session the record belongs to. * @param workspaceRoot - the resolved policy root (the session's immutable cwd). * @returns the provisioned record. @@ -95,8 +124,9 @@ export function sessionTempDir(sessionId: string): string { export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { const record: AclSessionRecord = { writeSid: randomWriteSid(), + sessionId: session.id, workspace: workspaceRoot, - tempDir: sessionTempDir(session.id), + tempDir: sessionTempDir(), } session.append('sandbox/acl-session', record) return record diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 93b92de0a0..e18d68449a 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -29,7 +29,7 @@ import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' import { provisionAclSession, sessionAclRecord } from './acl-session.ts' import type { AclSessionRecord } from './acl-session.ts' @@ -163,8 +163,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // The restricted token intersects every write access by construction, so - // the ACL runner governs every promised file effect — full enforcement. + // 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists + // close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are + // absent from both — pinned by the runner's Public-probe and CIM-denial + // regressions). FAT-class (non-ACL) targets are declared unsupported + // (warn-only) in the backend README — outside the promise, not an + // exception to it. 'windows-acl': 'full', } @@ -194,13 +198,19 @@ const DENIAL_SIGNATURES = { runnerCommand: ['read-only file system', 'permission denied'], } as const satisfies Record +/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */ +const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127 + /** * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit * 1 but its public contract does not reserve that status, while sandbox-exec * publishes no launcher-failure status; those backends remain signature-only. * The windows-acl runner prints `windows-acl-run: ` on every - * runner-side failure and exits 127. Keep the Landlock tuple aligned with the + * runner-side failure and exits 127 — the rule is exit-gated on that status + * so a confined command that merely PRINTS the signature (or a runner + * cleanup failure reported on a non-zero child exit) is never misclassified + * as "the command did not run". Keep the Landlock tuple aligned with the * assembled snapshot fixture at * `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`. */ @@ -212,7 +222,7 @@ const RUNNER_FAILURE_RULES = { informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`], }], seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }], - 'windows-acl': [{ fatalSignatures: ['windows-acl-run: '] }], + 'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }], } as const satisfies Record /** @@ -319,48 +329,63 @@ export class LocalSandboxProvider extends SandboxProvider { * the session log (provisioned on first use), its ACEs materialized once * per server lifetime, and the runner receives `--write-sid` plus the * session's PRIVATE temp subdirectory — it grants nothing and revokes - * nothing. Agentless calls (no session) pass no SID: the runner - * self-manages per-call grants on the ambient temp root. + * nothing. A fresh provision kicks an IMMEDIATE persistence flush right + * after the append (no write-behind debounce delay), narrowing the + * crash-and-lose-record window to the flush latency itself — the residual + * is documented in the README (the spawn seams are synchronous, so no + * await barrier exists between record and ACEs). Agentless calls (no + * session) pass no SID: the runner self-manages per-call grants on the + * ambient temp root. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { const sessionId = policy.sessionId - const record = sessionId === undefined ? undefined : this.aclSessionRecord(sessionId, policy.workspaceRoot) - if (record !== undefined) this.materializeAclGrant(record, policy.mode) + if (sessionId === undefined) { + return [ + ...this.windowsAclRunnerInvocation(), + '--workspace', policy.workspaceRoot, + '--temp', tmpdir(), + '--mode', policy.mode, + ] + } + const record = this.aclSessionRecord(sessionId, policy.workspaceRoot) + this.materializeAclGrant(record, policy.mode) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, // Workspace-write sessions confine their temp writes to the PRIVATE // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only - // and agentless runs pass the ambient temp root — the runner validates - // it exists but grants nothing (or self-manages, agentless only). - '--temp', policy.mode === 'workspace-write' && record !== undefined ? record.tempDir : tmpdir(), + // runs pass the ambient temp root — the runner validates it exists + // but grants nothing. + '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), '--mode', policy.mode, - ...record === undefined ? [] : ['--write-sid', record.writeSid], + '--write-sid', record.writeSid, ] } /** * Fold (or provision) the calling session's durable windows-acl record. * The provision appends exactly one log-only `sandbox/acl-session` event - * to the session log; the record's workspace must equal the policy root — - * both derive from the session's immutable cwd, so a mismatch is a - * corrupted composition and fails loud. + * to the session log and kicks an immediate persistence flush (the + * write-behind coordinator's bounded window would otherwise delay the + * record's durability past its ACE materialization); the record's + * workspace must equal the policy root — both derive from the session's + * immutable cwd, so a mismatch is a corrupted composition and fails loud. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. * @returns the session's record. */ - private aclSessionRecord(sessionId: string, workspaceRoot: string): AclSessionRecord { + private aclSessionRecord(sessionId: SessionId, workspaceRoot: string): AclSessionRecord { const store = this.ctx.get('sessions') if (store === undefined) { throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') } - const session = store.get(SessionId(sessionId)) + const session = store.get(sessionId) if (session === undefined) { throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) } - const existing = sessionAclRecord(session.events) + const existing = sessionAclRecord(session.events, sessionId) if (existing !== undefined) { if (existing.workspace !== workspaceRoot) { throw new Error( @@ -370,20 +395,30 @@ export class LocalSandboxProvider extends SandboxProvider { } return existing } - return provisionAclSession(session, workspaceRoot) + const record = provisionAclSession(session, workspaceRoot) + // Immediate durability kick: the append is write-behind (bounded + // coordinator window); flush now so the record is durable as close to + // its ACE materialization as the synchronous confine seam allows. The + // residual window (a crash inside the flush latency) can strand inert + // orphan-SID ACEs — documented in the README. + void store.flush(session) + return record } /** * Materialize the record's ACEs once per server lifetime: lazily at the * session's first confined execution, reused for every later call (the map * hit is the whole call). Workspace-write grants the workspace root and - * the private temp subdirectory (created here); read-only materializes - * NOTHING — its token alone restricts every write, and a standing grant - * from an earlier workspace-write period is KEPT through a downgrade - * (never revoked): the read-only restricted token carries no orphan SID - * (the read-only list), so the ACE is inert there, while the map hit keeps the - * re-upgrade free of re-propagation. Fail-closed: a half-materialized - * grant is revoked before the error propagates. + * the private temp subdirectory — created here EXCLUSIVELY (the name is + * random and unguessable, a pre-existing entry throws EEXIST, and a + * reparse point is rejected, so the grant never lands on an + * attacker-placed object); read-only materializes NOTHING — its token + * alone restricts every write, and a standing grant from an earlier + * workspace-write period is KEPT through a downgrade (never revoked): the + * read-only restricted token carries no orphan SID (the read-only list), + * so the ACE is inert there, while the map hit keeps the re-upgrade free + * of re-propagation. Fail-closed: a half-materialized grant is revoked + * before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ @@ -391,7 +426,10 @@ export class LocalSandboxProvider extends SandboxProvider { if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return const grant = AclWriteGrant.create(record.writeSid) try { - mkdirSync(record.tempDir, { recursive: true }) + // Exclusive creation (no `recursive`): a pre-existing entry OR a + // reparse point both fail EEXIST — the grant never lands on a foreign + // object. + mkdirSync(record.tempDir) grant.add(record.workspace) grant.add(record.tempDir) } catch (error) { diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index d1cca979a0..a0e7a38390 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -1,22 +1,19 @@ /** - * The windows-acl per-session grant: the DURABLE record (session-log event - * fold/provision) plus the SERVER-LIFETIME ACE materialization - * ({@link AclWriteGrant}), exercised through the REAL - * LocalSandboxProvider.confine() with a real session store. The Win32 surface - * is mocked at the package boundary (`@deepseek-ai/dsh-sandbox-windows-acl`), - * so these assertions run in every CI lane that runs sandbox-local's suites; - * the real-FFI grant behavior is pinned in @deepseek-ai/dsh-sandbox-windows- - * acl's own tests on win32 hosts. + * windows-acl per-session grant: the DURABLE record (log-event fold/provision + * with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE + * materialization, through the REAL LocalSandboxProvider.confine() with a + * real session store. Win32 surface mocked at the package boundary; the + * real-FFI grant behavior lives in sandbox-windows-acl's win32 tests. */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { sessionTempDir } from '../src/acl-session.ts' @@ -52,7 +49,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { }) /** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { writeSid: string; workspace: string; tempDir: string }): SessionEvent { +function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } } @@ -70,6 +67,11 @@ function workspaceRoot(): string { return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) } +/** A well-shaped private temp path under the host temp root (never created). */ +function shapedTempPath(): string { + return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) +} + describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const scratch: string[] = [] @@ -83,38 +85,30 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record, materializes the grant ONCE, and passes --write-sid + the private temp dir', async () => { + it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const privateTemp = sessionTempDir('sess-1') - scratch.push(privateTemp) const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-1' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(confined.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', privateTemp, - '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', - '--', - 'pwsh', '/Command', 'x', - ]) + expect(confined.argv).toContain('--write-sid') + expect(confined.argv).toContain('S-1-4-42-42') + expect(confined.argv).toContain('workspace-write') expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) - expect(existsSync(privateTemp)).toBe(true) // the private temp subdir was created + const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir + scratch.push(tempDir) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false }) + expect(existsSync(tempDir)).toBe(true) // created exclusively expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - // Reuse: the SECOND confine is the map hit — no new grant, no new event. - const second = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(second.argv).toEqual(confined.argv) + // Reuse: the second confine is the map hit. + sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(mockState.grants).toHaveLength(1) expect(session.events).toHaveLength(1) - // Provider dispose revokes the standing grant. await fiber.dispose() expect(mockState.grants[0]!.disposed).toBe(true) } finally { @@ -122,15 +116,60 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('read-only: the record still rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + + // read-only first: record rides along, nothing materialized, ambient temp. + const confinedRo = sandbox.confine(['true'], readOnly) + expect(confinedRo.argv).toContain('--write-sid') + expect(confinedRo.argv).toContain(tmpdir()) + expect(mockState.grants).toHaveLength(0) + const record = session.events.filter(event => event.type === 'sandbox/acl-session')[0]!.data as { tempDir: string } + scratch.push(record.tempDir) + expect(existsSync(record.tempDir)).toBe(false) + + // Upgrade: first workspace-write materializes with the same SID. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', record.tempDir, + '--mode', 'workspace-write', + '--write-sid', 'S-1-4-42-42', + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false }) + expect(existsSync(record.tempDir)).toBe(true) + + // Reuse: map hit. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(1) + + // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + } finally { + cleanup() + } + }) + + it('read-only: the record rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const privateTemp = sessionTempDir('sess-ro') - scratch.push(privateTemp) const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-ro' } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-ro') } const confined = sandbox.confine(['true'], policy) expect(confined.argv).toEqual([ @@ -143,69 +182,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(existsSync(privateTemp)).toBe(false) // no private temp dir under read-only - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - } finally { - cleanup() - } - }) - - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, and the downgrade keeps the standing grant (no revoke, no re-grant)', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const privateTemp = sessionTempDir('sess-switch') - scratch.push(privateTemp) - const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) - const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: 'sess-switch' } - const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-switch' } - - // Read-only first: the record still rides along (--write-sid, one - // event) but NOTHING is materialized and the ambient temp root is - // passed — the map stays empty, so the later upgrade must materialize. - const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', tmpdir(), - '--mode', 'read-only', - '--write-sid', 'S-1-4-42-42', - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(0) - expect(existsSync(privateTemp)).toBe(false) - - // Upgrade: the FIRST workspace-write confine materializes the grant - // (the map was empty — read-only never wrote it) with the SAME SID - // and the private temp dir, so the upgrade path cannot dead-end. - const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', privateTemp, - '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, privateTemp], disposed: false }) - expect(existsSync(privateTemp)).toBe(true) - - // Reuse: the second workspace-write call is the map hit. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(1) - - // Downgrade: the standing grant is KEPT — never revoked, never - // re-granted. The read-only restricted token's list carries no - // orphan SID (pinned by the windows-acl runner regression), so the - // ACE is inert under read-only while the map hit keeps the - // re-upgrade free of eager propagation. - sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(false) expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) } finally { cleanup() @@ -216,21 +192,20 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { try { const ws = workspaceRoot() scratch.push(ws) - const record = { writeSid: 'S-1-4-77-1', workspace: ws, tempDir: sessionTempDir('resumed') } - scratch.push(record.tempDir) + const tempDir = shapedTempPath() + const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir } + scratch.push(tempDir) const first = await setup() const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) - // The constructor appends the `session/end-seed` marker, so the log is - // the seed plus that marker — exactly one acl record among them. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'resumed' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } const confined = first.sandbox.confine(['true'], policy) expect(confined.argv).toContain('S-1-4-77-1') expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, record.tempDir] }) - // Replay IS the state: the seeded record satisfies the fold, nothing appended. + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] }) + // Replay IS the state: nothing appended. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) expect(session.events).toHaveLength(2) } finally { @@ -238,14 +213,93 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) + it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const parentTemp = shapedTempPath() + const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + scratch.push(parentTemp) + // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. + const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) + + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's + expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh + } finally { + cleanup() + } + }) + + it('fails loud on a matching-but-tampered record: a non-orphan write SID or a foreign temp path never materializes', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // writeSid = Everyone: would widen the grant to every token. + const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() } + ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) + const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } + expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) + expect(mockState.grants).toHaveLength(0) + + // tempDir outside the host temp root. + const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } + ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) + const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } + expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) + expect(mockState.grants).toHaveLength(0) + } finally { + cleanup() + } + }) + + it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => { + try { + const { ctx, sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + + // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. + const preexisting = shapedTempPath() + mkdirSync(preexisting) + scratch.push(preexisting) + const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } + ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) + const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } + expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) + expect(mockState.grants).toHaveLength(1) + expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked + + // Reparse point: same EEXIST (exclusive mkdir never follows links). + const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) + scratch.push(target) + const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name + symlinkSync(target, linkPath) + scratch.push(linkPath) + const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } + ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) + const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } + expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]!.disposed).toBe(true) + } finally { + cleanup() + } + }) + it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const mismatched = { writeSid: 'S-1-4-77-2', workspace: '/somewhere-else', tempDir: join(tmpdir(), 'dsh-x') } + const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'stale' } + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) expect(mockState.grants).toHaveLength(0) } finally { @@ -259,7 +313,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { await bare.plugin(LocalSandboxProvider, {}) const sandbox = bare.sandbox as LocalSandboxProvider sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: 'sess-none' } + const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: SessionId('sess-none') } expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) const { sandbox: withStore } = await setup() @@ -274,17 +328,17 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-add-fail' } + const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - // add() throws on the FIRST path: the cleanup dispose() runs and the - // original error propagates unchanged. + // add() throws on the FIRST path: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) - // add() AND dispose() both throw: both surface as an AggregateError. + // add() AND dispose() both throw: AggregateError. mockState.grants = [] mockState.addFailure = new Error('grant exploded again') mockState.disposeFailure = new Error('cleanup exploded') @@ -308,7 +362,6 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { 'pwsh', '/Command', 'x', ]) expect(mockState.grants).toHaveLength(0) - // Disposing a provider with no grants is a no-op (the empty-map guard). await fiber.dispose() } finally { cleanup() @@ -320,9 +373,10 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: 'sess-dispose' } + const session = ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) + scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) mockState.disposeFailure = new Error('revoke exploded') @@ -334,4 +388,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { cleanup() } }) + + it('sessionTempDir names are random and well-shaped (unpredictable, never derivable from the session id)', () => { + const a = basename(sessionTempDir()) + const b = basename(sessionTempDir()) + expect(a).not.toBe(b) + expect(a).toMatch(/^dsh-[0-9a-f]{16}$/) + expect(b).toMatch(/^dsh-[0-9a-f]{16}$/) + }) }) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index e97ea17491..1f15765335 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -383,7 +383,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) expect(confined.enforcement).toBe('full') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) it('reads a failing probe as unusable and walks to the next rung', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 834cebbe3f..3fb0ec6692 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: e9309caa874a33d3df32cc23b50c0d6375ee3066 -README.zh.md: eabf8f1c1e7e985960beee5abb540a33d052a05e +README.md: 80d0502c3ace71da49e8f40efc6aa379286c74ac +README.zh.md: b0c50af1b339a9fdc206778b6184b8e9121ef19c diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index e9309caa87..80d0502c3a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system. +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). @@ -38,7 +38,7 @@ node runner.js --workspace --temp --mode The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log, so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. +**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log (bound to the owning session id, validated at the fold), so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. @@ -64,7 +64,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-`); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. - **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. @@ -80,6 +80,7 @@ None directly; the denial surface belongs to the tool layer. - **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). - **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. - **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index eabf8f1c1e..b0c50af1b3 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -4,7 +4,7 @@ 面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 @@ -38,7 +38,7 @@ node runner.js --workspace --temp --mode runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 -**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志,因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 +**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志(绑定其所属会话 id,在 fold 处校验),因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 @@ -64,7 +64,7 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 - **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-`);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-<16 random hex>`,独占创建——已有条目或 reparse point 会响亮失败);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 - **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 - **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 @@ -80,6 +80,7 @@ g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && - **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 - **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **NULL DACL 目录在 grant+revoke 下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,而 revoke 往返之后留下的是**空**(deny-all)DACL,而非原来的 NULL DACL。POC 也有同样行为;真实的工作区与临时目录都带真实 DACL,因此这仍是一条记录在案的边角,而非被守护的路径。 - **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 - **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 - **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index dd0124edda..309fe8fe50 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -123,6 +123,9 @@ export interface Win32Bindings { createJobObjectW(attributes: null, name: null): NativePtr setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number assignProcessToJobObject(job: NativePtr, process: NativePtr): number + // Terminate a suspended child that could not be placed in the kill-on-close + // job — closing handles alone would leave it hanging forever. + terminateProcess(process: NativePtr, exitCode: number): number // ---- console ------------------------------------------------------------- // HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h): // the runner survives console Ctrl+C so the child handles its own and the @@ -417,6 +420,7 @@ function bindings(): Win32Bindings { createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']), setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']), assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]), + terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']), setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']), getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']), } as unknown as Win32Bindings diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index f22dadfbb0..93e0e33c69 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -5,8 +5,14 @@ * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only * this sandbox instance adds to the target directories' DACLs — the * intersection check then allows writes exactly where that SID has a Write - * ACE, and nowhere else. Unlike the POC, every API failure throws with the - * API name and exact Win32 code; a child is NEVER spawned unrestricted. + * ACE, and nowhere else the orphan SID is concerned (the token's write check + * ALSO inherits the ambient write ACEs of the other restricting SIDs — the + * keep-alive group logon SID + Everyone; Authenticated Users, + * INTERACTIVE, and LOCAL are absent from both lists — see the seam's + * dual-list contract in `packages/sandbox/sandbox-local` and the package + * README's Modes section for the complete boundary). Unlike the POC, every + * API failure throws with the API name and exact Win32 code; a child is + * NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): * - writes are restricted; reads, network, and process visibility are NOT diff --git a/packages/sandbox/sandbox-windows-acl/src/spawn.ts b/packages/sandbox/sandbox-windows-acl/src/spawn.ts index b71efe2ac7..eafcb252ce 100644 --- a/packages/sandbox/sandbox-windows-acl/src/spawn.ts +++ b/packages/sandbox/sandbox-windows-acl/src/spawn.ts @@ -331,7 +331,11 @@ export function spawnSandboxedInherited( } if (api.assignProcessToJobObject(job, processHandle) === 0) { + // The child was created suspended and is NOT in the kill-on-close job: + // closing handles would leave it suspended forever. Terminate it first, + // then drop the handles and throw. const win32Code = api.getLastError() + api.terminateProcess(processHandle, 1) api.closeHandle(threadHandle) api.closeHandle(processHandle) api.closeHandle(job) diff --git a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts index 14f370602c..0c1d7f42b3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/failure-paths.spec.ts @@ -102,6 +102,32 @@ describe('spawn failure paths close their handles', () => { expect(closeHandle).toHaveBeenCalledTimes(3) expect(closed).toEqual([201n, 200n, 100n]) }) + + it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => { + // The child is created suspended and is NOT in the kill-on-close job when + // the assignment fails: closing the job cannot kill it, so the failure + // branch must TerminateProcess first or every failure strands a hanging + // orphan forever. + const { api: baseApi, closeHandle } = resumeFailureApi() + type JobFailureApi = Win32Bindings & { + assignProcessToJobObject: ReturnType + terminateProcess: ReturnType + } + const api = baseApi as JobFailureApi + api.assignProcessToJobObject = vi.fn(() => 0) + api.terminateProcess = vi.fn(() => 1) + let caught: unknown + try { + spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' }) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AssignProcessToJobObject') + expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1) + // thread, process, job — and the child is already dead before they close. + expect(closeHandle).toHaveBeenCalledTimes(3) + }) }) describe('getTempPath buffer defense', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index f2737948ca..2557b1f43b 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -43,7 +43,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { ]) expect(confined.enforcement).toBe('full') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) // A sole candidate is selected unprobed. expect(probeWindowsAcl).not.toHaveBeenCalled() }) @@ -53,6 +53,6 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { const confined = sandbox.confine(['true'], RO) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) expect(confined.enforcement).toBe('full') - expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }]) + expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) }) diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index deb703dc15..7c91afe76b 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -27,11 +27,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index fee9164ead..6c2397a2e0 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -7,6 +7,7 @@ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' export { ESCALATION_TARGETS, @@ -41,12 +42,12 @@ export interface SandboxExecutionPolicy { /** Absolute root directory `workspace-write` may write under. */ workspaceRoot: string /** - * Opaque identity of the calling session (the `dsh-session` SessionId in - * string form). Backends key per-session state off it (e.g. the windows-acl + * Opaque identity of the calling session (the branded `dsh-session` + * SessionId). Backends key per-session state off it (e.g. the windows-acl * per-session write grant and private temp subdirectory); absent for * agentless calls, which fall back to per-call backend state. */ - sessionId?: string + sessionId?: SessionId } /** diff --git a/packages/sandbox/sandbox/tsconfig.json b/packages/sandbox/sandbox/tsconfig.json index af4de1c016..673ee51547 100644 --- a/packages/sandbox/sandbox/tsconfig.json +++ b/packages/sandbox/sandbox/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../core/session" + }, { "path": "../../support/invariants" } From 18859c408c16476b082aad86b2df7561e414d72a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 8 Aug 2026 23:59:52 +0800 Subject: [PATCH 42/81] fix(sandbox): record the dsh-session dependency in the lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bdb9cf308..4df6dd66d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4641,6 +4641,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis From db7bcbdf91099d150d9b4ce6ca3ea4000bae768f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 00:06:50 +0800 Subject: [PATCH 43/81] test(sandbox): cover the durable-record typeof guards and the empty-workspace branch --- .../sandbox-local/tests/acl-session.spec.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index a0e7a38390..51b3dec417 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -234,7 +234,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('fails loud on a matching-but-tampered record: a non-orphan write SID or a foreign temp path never materializes', async () => { + it('fails loud on a matching-but-tampered record: non-orphan SID, foreign temp path, and non-string fields never materialize', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -245,13 +245,26 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) - expect(mockState.grants).toHaveLength(0) // tempDir outside the host temp root. const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) + + // Non-string durable fields (a corrupted/tampered JSONL payload): the + // typeof guards fail loud before any string operation runs. + const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ + { id: 'tampered-type-sid', record: { writeSid: 42, sessionId: SessionId('tampered-type-sid'), workspace: ws, tempDir: shapedTempPath() }, expect: /malformed write SID/ }, + { id: 'tampered-type-ws-null', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-ws-empty', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-temp', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, + ] + for (const c of cases) { + ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId(c.id) } + expect(() => sandbox.confine(['true'], policy), c.id).toThrow(c.expect) + } expect(mockState.grants).toHaveLength(0) } finally { cleanup() From 2bfc8273ee7e29bfbf93a585b752ca1bb9de0fe9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 01:56:53 +0800 Subject: [PATCH 44/81] fix(pty-local): expect sessionId in resolved policy after merge-forward Master's pty-local rewrite dropped the sessionId expectation from the resolved-policy contract; sandbox-policy resolve() injects the owner sessionId (sandbox branch design). Restore it in both expectations. --- packages/pty/pty-local/tests/index.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index d61eb58da5..dd3a956536 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -215,7 +215,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, }]) }) @@ -247,7 +247,7 @@ describe('LocalPtyBackend startup rollback', () => { }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, }]) }) From 55fd6e555ac1995b4a24032ebad2a3ce6b06c02b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 02:14:45 +0800 Subject: [PATCH 45/81] fix(knip): restore pwsh-sandbox entry body mangled by merge The merge-forward collapsed the pwsh-sandbox key with the parent's e2b entry into invalid JSON; restore the branch's original entry alongside the kept e2b entry. --- knip.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/knip.json b/knip.json index f39ee8d302..de77396c7c 100644 --- a/knip.json +++ b/knip.json @@ -233,6 +233,15 @@ ] }, "packages/bash/pwsh-sandbox": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/e2b/e2b": { "entry": [ "tests/**/*.spec.ts", From d18dc76b899129fa09f7bf672ae9712b49b2771e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 03:19:09 +0800 Subject: [PATCH 46/81] chore(docs): regenerate module graph for merged tree The merge-forward took the parent's module-graph verbatim; the merged tree adds the sandbox-windows-acl package and dsh-session edges. verify-module-graph is a ci-static gate, not in local doc-sync. --- docs/module-graph.md | 157 ++++++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 6f47c36e97..4ea724f789 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -43,6 +43,7 @@ flowchart TD pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_pwsh_local["pwsh-local"] + pkg_pwsh_sandbox["pwsh-sandbox"] pkg_tool_bash["tool-bash"] pkg_tool_pwsh["tool-pwsh"] end @@ -230,6 +231,7 @@ flowchart TD pkg_sandbox["sandbox"] pkg_sandbox_local["sandbox-local"] pkg_sandbox_policy["sandbox-policy"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] end subgraph group_scaffold["packages/scaffold"] pkg_helper["helper"] @@ -313,6 +315,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants pkg_type_meta --> pkg_invariants @@ -414,8 +417,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_sandbox --> pkg_invariants - pkg_sandbox --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths @@ -426,13 +427,6 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_agent --> pkg_type_meta - pkg_bash --> pkg_invariants - pkg_bash --> pkg_sandbox - pkg_bash --> pkg_subprocess - pkg_fs --> pkg_brand - pkg_fs --> pkg_invariants - pkg_fs --> pkg_llm - pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_compact --> pkg_invariants @@ -491,9 +485,9 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_sandbox_local --> pkg_invariants - pkg_sandbox_local --> pkg_llm - pkg_sandbox_local --> pkg_sandbox + pkg_sandbox --> pkg_invariants + pkg_sandbox --> pkg_llm + pkg_sandbox --> pkg_session pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_invariants pkg_session_persistence --> pkg_session @@ -518,22 +512,13 @@ flowchart TD pkg_goal --> pkg_session pkg_goal --> pkg_session_projection pkg_goal --> pkg_type_meta - pkg_bash_local --> pkg_bash - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_bash - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_policy --> pkg_fs - pkg_fs_policy --> pkg_invariants - pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_invariants - pkg_skill_local --> pkg_paths - pkg_skill_local --> pkg_skill + pkg_bash --> pkg_invariants + pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess + pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants + pkg_fs --> pkg_llm + pkg_fs --> pkg_sandbox pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_environment @@ -542,9 +527,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_hook_protocol --> pkg_bash - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session pkg_llm_replay --> pkg_compact pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm @@ -565,13 +547,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_host_directory_picker_browse --> pkg_client_locale pkg_host_directory_picker_browse --> pkg_client_runtime pkg_host_directory_picker_browse --> pkg_client_ui_primitives @@ -597,16 +572,13 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_lsp_local --> pkg_brand - pkg_lsp_local --> pkg_fs - pkg_lsp_local --> pkg_invariants - pkg_lsp_local --> pkg_llm - pkg_lsp_local --> pkg_lsp - pkg_lsp_local --> pkg_subprocess - pkg_lsp_local --> pkg_timeout pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants + pkg_sandbox_local --> pkg_invariants + pkg_sandbox_local --> pkg_llm + pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_local --> pkg_session pkg_sandbox_policy --> pkg_agent pkg_sandbox_policy --> pkg_invariants pkg_sandbox_policy --> pkg_sandbox @@ -664,16 +636,22 @@ flowchart TD pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session - pkg_bash_sandbox --> pkg_bash - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_fs_sandbox --> pkg_fs - pkg_fs_sandbox --> pkg_fs_local - pkg_fs_sandbox --> pkg_invariants - pkg_fs_sandbox --> pkg_sandbox - pkg_fs_sandbox --> pkg_sandbox_policy + pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_bash + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_policy --> pkg_fs + pkg_fs_policy --> pkg_invariants + pkg_skill_local --> pkg_fs + pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths + pkg_skill_local --> pkg_skill pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compact pkg_command_compact --> pkg_invariants @@ -682,6 +660,9 @@ flowchart TD pkg_compact_tool_result_prune --> pkg_llm pkg_compact_tool_result_prune --> pkg_session pkg_compact_tool_result_prune --> pkg_token_meter + pkg_hook_protocol --> pkg_bash + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -705,6 +686,13 @@ flowchart TD pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_conversation --> pkg_token_meter + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -721,6 +709,13 @@ flowchart TD pkg_permission --> pkg_session_projection pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval + pkg_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_fs + pkg_lsp_local --> pkg_invariants + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess + pkg_lsp_local --> pkg_timeout pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -757,6 +752,21 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_bash_sandbox --> pkg_bash + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_bash + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_fs_sandbox --> pkg_fs + pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants + pkg_fs_sandbox --> pkg_sandbox + pkg_fs_sandbox --> pkg_sandbox_policy pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -982,9 +992,12 @@ flowchart TD pkg_tool_pwsh --> pkg_bash_env pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_llm + pkg_tool_pwsh --> pkg_sandbox + pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_tool_pwsh --> pkg_user_approval pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1193,6 +1206,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | @@ -1227,11 +1241,8 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1247,34 +1258,28 @@ flowchart TD | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | -| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`scripts`](../packages/scaffold/scripts) | `scaffold` | [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1288,23 +1293,33 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | @@ -1340,7 +1355,7 @@ flowchart TD | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | From 5fea4b7c4b86db5504b27fabd5023a3a1d03829d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 10:44:35 +0800 Subject: [PATCH 47/81] feat(sandbox): derive the windows-acl write SID per workspace, not per session The per-session random write SID forced a full tree propagation per session per server lifetime (minutes on large workspaces). The write SID is now the per-workspace identity derived from the canonical workspace path (workspaceWriteSid: sha256 -> S-1-4-x-y), stored nowhere: the workspace-root ACE materializes once per workspace per machine and every later provision hits the exact-ACE skip. - workspace ACEs are STANDING (never revoked - the reuse cache); temp ACEs stay revocable (disposed with the provider), so an inheritable ACE never outlives its session's temp dir on the ambient temp root - AclSandbox requires the write SID under workspace-write; read-only parses/grants nothing; the runner derives the SID itself (the --write-sid flag's presence still marks the seam-managed contract) - the acl-session record drops writeSid (sessionId/workspace/tempDir remain): the SID-tamper surface and its validation are gone - sandbox-local holds two grant maps: standing workspace grants and revocable per-session temp grants Docs (README pair, design note pair, catalogs, type-equiv) and the acl-session/grant/acl/probe/runner suites updated; workspace-sid.spec pins the derivation contract. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/config-catalog.md | 2 +- docs/persistence-catalog.md | 10 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 7 +- docs/subsystems/sandbox.zh.md | 7 +- .../sandbox/sandbox-local/src/acl-session.ts | 67 ++++---- packages/sandbox/sandbox-local/src/index.ts | 121 +++++++++----- .../sandbox-local/tests/acl-session.spec.ts | 150 ++++++++++-------- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 34 ++-- .../sandbox/sandbox-windows-acl/README.zh.md | 78 ++++----- .../sandbox/sandbox-windows-acl/src/grant.ts | 41 +++-- .../sandbox/sandbox-windows-acl/src/index.ts | 138 +++++++++------- .../sandbox/sandbox-windows-acl/src/runner.ts | 48 +++--- .../sandbox/sandbox-windows-acl/src/token.ts | 16 +- .../sandbox-windows-acl/src/workspace-sid.ts | 38 +++++ .../sandbox-windows-acl/tests/acl.spec.ts | 31 +++- .../sandbox-windows-acl/tests/grant.spec.ts | 24 ++- .../sandbox-windows-acl/tests/probe.spec.ts | 2 +- .../tests/workspace-sid.spec.ts | 30 ++++ packages/sandbox/sandbox/src/index.ts | 5 +- 24 files changed, 543 insertions(+), 330 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index c9e3414e4d..98d1f7f653 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 6c358c1bdcccb8ca2260abfcc6743a0dd4b852b8 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 028b7dedf53944c4f00d2db37bd6af213d94fed0 +2026-08-08-windows-acl-restricted-token-sandbox.md: 54468fa46f7dbf7bcb7f765b6ca6bb7ed962e7d2 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10a412a2cb4f584443ca50436ff1baf65141ac1e diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 6c358c1bdc..54468fa46f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include an orphan SID (`S-1-4-x-y`); the orphan SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The orphan SID is PER SESSION, not per spawn: the seam provisions one SID per session as a log-only `sandbox/acl-session` event on the session log (fork mints a fresh one; resume replays the same one), materializes its ACEs lazily at the session's first confined execution — a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency; a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap — and holds them for the server process's lifetime (revoked on provider dispose; idempotent re-grant skips the eager full-tree re-propagation when the exact ACE survives a restart — no garbage collection). The record is BOUND to its owning session id and validated at the fold (orphan-SID shape, temp path inside the host temp root): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the orphan SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, orphan]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no orphan: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the unrevoked ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories, revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per session per server lifetime by the per-session reuse; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The per-session grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the write SID and temp path, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume SID reuse, dispose revocation, and the mode-switch cycle — with the Win32 surface mocked) and on win32 by `grant.spec.ts` (real-DACL materialization), the `acl.spec.ts` idempotent-grant fast-path, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 028b7dedf5..10a412a2cb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含孤儿 SID(`S-1-4-x-y`);工作区与临时目录上孤儿 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。孤儿 SID 按会话而非按 spawn:seam 每会话供给一个 SID,作为 log-only 的 `sandbox/acl-session` 事件记录在会话日志中(fork 铸出新 SID;恢复回放同一个),其 ACE 在该会话首次受限执行时惰性物化——新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化;在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口——并在服务器进程生命周期内持有(提供方 dispose(资源释放)时回收;幂等重授权在该 ACE 跨重启原样存续时跳过急切的全树重传播——不做垃圾回收)。记录被**绑定**到其所属会话 id 并在 fold 处校验(孤儿 SID 形态、临时路径须位于宿主临时根之内):fork 复制的父记录绝不会为子会话供给 SID,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的孤儿 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、孤儿 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含孤儿 SID:先前 workspace-write 时期留下的驻留授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而未撤销的 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有,由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因每会话复用,每个服务器生命周期每会话只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。每会话授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给 SID——写 SID 与临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复 SID 复用、dispose 回收,以及模式切换循环——mock 掉 Win32 表面),win32 侧由 `grant.spec.ts`(真实 DACL 物化)、`acl.spec.ts` 的幂等授权快速路径、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e64a858e0a..8de276a4a9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1231,7 +1231,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:39`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c1e057542f..c1eae60ced 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -498,16 +498,16 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ```ts persistence-catalog /** - * The session's windows-acl write identity was provisioned — log-only + * The session's windows-acl write record was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such * event owned by the session is its record ({@link sessionAclRecord}); * the provider appends exactly one on the session's first Windows - * confined execution. + * confined execution. The write SID itself is NOT stored — it is the + * per-workspace identity derived from `workspace` + * (`workspaceWriteSid`). */ 'sandbox/acl-session': { - /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session — the binding a fork's copied event cannot satisfy. */ sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ @@ -517,7 +517,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ } ``` -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:36`](../packages/sandbox/sandbox-local/src/acl-session.ts) +Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) #### `sandbox/mode` — log-only diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index abef39cbf9..489daf02e4 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: 37aba27c589be80d8a3ea9201fd8d13c7b259eda -sandbox.zh.md: 244e351a5db1bfb0d2c9069e8d07bd52f9d15bb0 +sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46 +sandbox.zh.md: 0619046f2fa98a89275e2dad85f0eadaf8e4a231 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 37aba27c58..20e0f36a5e 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -56,8 +56,9 @@ interface SandboxExecutionPolicy { /** * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. */ sessionId?: SessionId } @@ -183,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 244e351a5d..0619046f2f 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -56,8 +56,9 @@ interface SandboxExecutionPolicy { /** * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. */ sessionId?: SessionId } @@ -183,7 +184,7 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` -Source: [`packages/sandbox/sandbox/src/index.ts:157`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/sandbox/src/index.ts) diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts index 5247601250..2807ffd8a1 100644 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ b/packages/sandbox/sandbox-local/src/acl-session.ts @@ -1,18 +1,24 @@ /** - * The windows-acl per-session write identity — the DURABLE half of the seam's - * per-session grant reuse. Each session owns exactly one record (one orphan - * write SID, one private temp subdirectory), stored as a log-only + * The windows-acl session write record — the DURABLE half of the seam's + * grant lifecycle. Each session owns exactly one record (its workspace + * binding plus one private temp subdirectory), stored as a log-only * `sandbox/acl-session` event on the session log (the `sandbox/mode` * precedent): replayable, never in the model transcript, and no external - * config store. The ACE half is server-lifetime state owned by the provider - * ({@link AclWriteGrant} materialization, revoked on dispose); the record - * survives restarts so a resumed session reuses the SAME SID — re-granting - * idempotently merges into (or skips) the standing ACEs instead of leaking a - * fresh dead SID's ACEs per restart. The record is BOUND to its owning - * session id, so a fork (which copies the parent's events, record included) - * never inherits the parent's identity — it provisions a fresh one. The - * record's payload is durable input and is validated at the fold (orphan-SID - * shape, well-formed temp path); a matching-but-tampered record fails loud. + * config store. The record carries NO SID: the write SID is the + * per-WORKSPACE identity derived from the workspace path + * (`workspaceWriteSid`) — deterministic across sessions and server + * restarts, so the workspace-root ACE materializes once per workspace per + * machine (the grant's exact-ACE skip makes every later provision O(1)) + * instead of once per session. The ACE half is server-lifetime state owned + * by the provider ({@link AclWriteGrant}: workspace ACEs standing, temp ACEs + * revocable); the record survives restarts so a resumed session reuses the + * SAME private temp subdirectory and the same derived SID — re-granting + * idempotently merges into (or skips) the standing ACEs. The record is + * BOUND to its owning session id, so a fork (which copies the parent's + * events, record included) never inherits the parent's temp identity — it + * provisions a fresh one. The record's payload is durable input and is + * validated at the fold (well-formed workspace/temp paths); a + * matching-but-tampered record fails loud. * * @module dsh-sandbox-local/acl-session */ @@ -20,22 +26,21 @@ import { randomBytes } from 'node:crypto' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * The session's windows-acl write identity was provisioned — log-only + * The session's windows-acl write record was provisioned — log-only * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): * durable and replayable, never in the model transcript. The LAST such * event owned by the session is its record ({@link sessionAclRecord}); * the provider appends exactly one on the session's first Windows - * confined execution. + * confined execution. The write SID itself is NOT stored — it is the + * per-workspace identity derived from `workspace` + * (`workspaceWriteSid`). */ 'sandbox/acl-session': { - /** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session — the binding a fork's copied event cannot satisfy. */ sessionId: SessionId /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ @@ -48,24 +53,19 @@ declare module '@deepseek-ai/dsh-session' { /** The durable per-session record carried by one `sandbox/acl-session` event. */ export interface AclSessionRecord { - /** The orphan write SID whose ACEs form the session's write allowlist. */ - writeSid: string /** The owning session id (binds the record against fork inheritance). */ sessionId: SessionId - /** The workspace root the record was provisioned for. */ + /** The workspace root the record was provisioned for (the write SID derives from it). */ workspace: string /** The session's private temp subdirectory. */ tempDir: string } -/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */ -const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u - /** * The session's record: the last `sandbox/acl-session` event owned by it, or * undefined (never confined / a fork). Durable-input validation: tampered - * SID or temp path fails loud. @param events/@param sessionId/@returns as - * below. + * workspace or temp path fails loud. @param events/@param sessionId/@returns + * as below. * @param events - session events (other types skipped). * @param sessionId - owning session (fork binding). * @returns the last owned record, or undefined without one. @@ -77,12 +77,6 @@ export function sessionAclRecord(events: readonly SessionEvent[], sessionId: Ses const data = event.data // Fork copies the parent's record: skip non-owned records (fork mints fresh). if (data.sessionId !== sessionId) continue - if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} ` - + '(expected the orphan shape S-1-4-x-y)', - ) - } if (typeof data.workspace !== 'string' || data.workspace.length === 0) { throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) } @@ -112,18 +106,17 @@ export function sessionTempDir(): string { /** * Provision the record for a session that has none (its first Windows - * confined execution): a fresh write SID plus the private temp subdirectory, - * appended as exactly one log-only `sandbox/acl-session` event — the - * provision IS its event, nothing mutates record state out of band. Fork - * (whose copied parent record is not its own) provisions a fresh record; - * resume replays the stored one. + * confined execution): the workspace binding plus the private temp + * subdirectory, appended as exactly one log-only `sandbox/acl-session` + * event — the provision IS its event, nothing mutates record state out of + * band. Fork (whose copied parent record is not its own) provisions a fresh + * record; resume replays the stored one. * @param session - the session the record belongs to. * @param workspaceRoot - the resolved policy root (the session's immutable cwd). * @returns the provisioned record. */ export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { const record: AclSessionRecord = { - writeSid: randomWriteSid(), sessionId: session.id, workspace: workspaceRoot, tempDir: sessionTempDir(), diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index e18d68449a..0bf982607f 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -5,12 +5,16 @@ * classification facts. Missing or unusable confinement fails closed rather * than returning the original argv. * - * The windows-acl rung additionally owns the per-session write grant: one - * orphan write SID and one private temp subdirectory per session (durable - * record in the session log — see `./acl-session.ts`), ACEs materialized - * lazily at the session's first confined execution and held for the SERVER - * process's lifetime (revoked on dispose). The runner receives `--write-sid` - * and stops managing DACLs itself. + * The windows-acl rung additionally owns the write grants: the write SID is + * the per-WORKSPACE identity derived from the canonical workspace path + * (`workspaceWriteSid`), and one private temp subdirectory per session + * (durable record in the session log — see `./acl-session.ts`). The + * workspace-root ACE materializes once per workspace per server lifetime + * and STANDS (the cross-session reuse cache — the exact-ACE skip makes + * every later provision O(1) instead of re-propagating the tree per + * session); the private-temp ACEs are revoked on dispose. The runner + * receives `--write-sid` (the derived identity; its presence marks the + * seam-managed contract) and stops managing DACLs itself. * @module @deepseek-ai/dsh-sandbox-local */ @@ -30,7 +34,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { provisionAclSession, sessionAclRecord } from './acl-session.ts' import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -227,9 +231,10 @@ const RUNNER_FAILURE_RULES = { /** * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the - * chain verdict and, on the windows-acl rung, the per-session write grants - * ({@link AclWriteGrant}, one per session, revoked on provider dispose); the - * one-time probes spawn nothing else. + * chain verdict and, on the windows-acl rung, the write grants + * ({@link AclWriteGrant}: the standing workspace-root grant per workspace + * and the revocable private-temp grant per session, the latter revoked on + * provider dispose); the one-time probes spawn nothing else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -248,11 +253,15 @@ export class LocalSandboxProvider extends SandboxProvider { /** Cached chain verdict; undefined until the first confined wrap needs it. */ private selectedRunner: SelectedRunner | 'unavailable' | undefined /** - * Server-lifetime per-session write grants (windows-acl rung), keyed by the - * session's orphan write SID — the native half of the per-session reuse; - * the durable half lives in the session log (`./acl-session.ts`). + * Server-lifetime write grants (windows-acl rung): the STANDING + * workspace-root grant per workspace (its ACE is the cross-session reuse + * cache and outlives the provider — never revoked) and the REVOCABLE + * private-temp grant per session (revoked on provider dispose); the + * durable half (workspace binding + private temp dir) lives in the + * session log (`./acl-session.ts`). */ - private readonly aclGrants = new Map() + private readonly workspaceGrants = new Map() + private readonly tempGrants = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -274,9 +283,10 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) - // Standing ACL grants are revoked with the provider: a clean server - // shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs - // the session's durable record re-grants idempotently on resume). + // The temp grants are revoked with the provider: a clean server + // shutdown leaves no temp ACEs behind (workspace ACEs stand by design — + // the reuse cache; an unclean shutdown leaves them for the next + // provision's exact-ACE skip). ctx.effect(() => () => { this.revokeAclGrants() }) @@ -357,10 +367,11 @@ export class LocalSandboxProvider extends SandboxProvider { // Workspace-write sessions confine their temp writes to the PRIVATE // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only // runs pass the ambient temp root — the runner validates it exists - // but grants nothing. + // but grants nothing. The derived write SID is the per-workspace + // identity; the flag's presence marks the seam-managed DACL contract. '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), '--mode', policy.mode, - '--write-sid', record.writeSid, + '--write-sid', workspaceWriteSid(record.workspace), ] } @@ -399,8 +410,8 @@ export class LocalSandboxProvider extends SandboxProvider { // Immediate durability kick: the append is write-behind (bounded // coordinator window); flush now so the record is durable as close to // its ACE materialization as the synchronous confine seam allows. The - // residual window (a crash inside the flush latency) can strand inert - // orphan-SID ACEs — documented in the README. + // residual window (a crash inside the flush latency) strands the + // private temp directory unrecorded — documented in the README. void store.flush(session) return record } @@ -408,29 +419,49 @@ export class LocalSandboxProvider extends SandboxProvider { /** * Materialize the record's ACEs once per server lifetime: lazily at the * session's first confined execution, reused for every later call (the map - * hit is the whole call). Workspace-write grants the workspace root and - * the private temp subdirectory — created here EXCLUSIVELY (the name is - * random and unguessable, a pre-existing entry throws EEXIST, and a - * reparse point is rejected, so the grant never lands on an - * attacker-placed object); read-only materializes NOTHING — its token - * alone restricts every write, and a standing grant from an earlier - * workspace-write period is KEPT through a downgrade (never revoked): the - * read-only restricted token carries no orphan SID (the read-only list), - * so the ACE is inert there, while the map hit keeps the re-upgrade free - * of re-propagation. Fail-closed: a half-materialized grant is revoked - * before the error propagates. + * hits are the whole call). The write SID is the per-workspace identity + * derived from the record's workspace. Workspace-write grants the + * workspace root STANDING (the ACE outlives every session — the reuse + * cache) and the private temp subdirectory REVOCABLY — created here + * EXCLUSIVELY (the name is random and unguessable, a pre-existing entry + * throws EEXIST, and a reparse point is rejected, so the grant never + * lands on an attacker-placed object); read-only materializes NOTHING — + * its token alone restricts every write, and the standing grant from an + * earlier workspace-write period is KEPT through a downgrade (never + * revoked): the read-only restricted token carries no write SID (the + * read-only list), so the ACE is inert there, while the map hit keeps the + * re-upgrade free of re-propagation. Fail-closed: a half-materialized + * temp grant is revoked before the error propagates. * @param record - the session's durable record. * @param mode - the policy mode (grants exist only under workspace-write). */ private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { - if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return - const grant = AclWriteGrant.create(record.writeSid) + if (mode === 'read-only') return + const writeSid = workspaceWriteSid(record.workspace) + if (!this.workspaceGrants.has(record.workspace)) { + const grant = AclWriteGrant.create(writeSid) + try { + grant.add(record.workspace, true) + } catch (error) { + // Free the SID; a standing ACE (if the apply succeeded before a + // post-apply throw) is the intended end state, not an error + // artifact — nothing to revoke. + try { + grant.dispose() + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed') + } + throw error + } + this.workspaceGrants.set(record.workspace, grant) + } + if (this.tempGrants.has(record.sessionId)) return + const grant = AclWriteGrant.create(writeSid) try { // Exclusive creation (no `recursive`): a pre-existing entry OR a // reparse point both fail EEXIST — the grant never lands on a foreign // object. mkdirSync(record.tempDir) - grant.add(record.workspace) grant.add(record.tempDir) } catch (error) { // Revoke whatever stands and free the SID — never leave a half-grant @@ -438,30 +469,32 @@ export class LocalSandboxProvider extends SandboxProvider { try { grant.dispose() } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed') + throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') } throw error } - this.aclGrants.set(record.writeSid, grant) + this.tempGrants.set(record.sessionId, grant) } /** - * Revoke every standing per-session grant and free every SID (provider - * dispose). Cleanup failures are reported, not thrown: cordis teardown - * must not be aborted by grant revocation, and the durable records make a - * missed revocation self-healing on the next resume. + * Dispose every write grant (provider dispose): the revocable temp ACEs + * are revoked and every SID allocation freed; the standing workspace ACEs + * stay (the reuse cache). Cleanup failures are reported, not thrown: + * cordis teardown must not be aborted by grant cleanup, and the durable + * records make a missed revocation self-healing on the next resume. */ private revokeAclGrants(): void { - if (this.aclGrants.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return const failures: unknown[] = [] - for (const grant of this.aclGrants.values()) { + for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { try { grant.dispose() } catch (error) { failures.push(error) } } - this.aclGrants.clear() + this.workspaceGrants.clear() + this.tempGrants.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 51b3dec417..ff04b97360 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -1,9 +1,11 @@ /** - * windows-acl per-session grant: the DURABLE record (log-event fold/provision - * with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE - * materialization, through the REAL LocalSandboxProvider.confine() with a - * real session store. Win32 surface mocked at the package boundary; the - * real-FFI grant behavior lives in sandbox-windows-acl's win32 tests. + * windows-acl write grants: the DURABLE record (log-event fold/provision with + * ownership binding + tamper validation) plus the SERVER-LIFETIME ACE + * materialization (standing workspace grant per workspace, revocable temp + * grant per session), through the REAL LocalSandboxProvider.confine() with a + * real session store. Win32 surface mocked at the package boundary (the + * workspace-derived SID mocked to a constant); the real-FFI grant behavior + * lives in sandbox-windows-acl's win32 tests. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' @@ -19,7 +21,7 @@ import { sessionTempDir } from '../src/acl-session.ts' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ - grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>, + grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -27,7 +29,7 @@ const mockState = vi.hoisted(() => ({ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { class MockAclWriteGrant { readonly writeSid: string - readonly added: string[] = [] + readonly added: Array<{ path: string; standing: boolean }> = [] disposed = false constructor(writeSid: string) { this.writeSid = writeSid @@ -36,20 +38,23 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { static create(writeSid: string): MockAclWriteGrant { return new MockAclWriteGrant(writeSid) } - add(path: string): void { + add(path: string, standing = false): void { if (mockState.addFailure !== undefined) throw mockState.addFailure - this.added.push(path) + this.added.push({ path, standing }) } dispose(): void { if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure this.disposed = true } } - return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' } + return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } }) +/** The workspace-derived write SID the mock pins for every workspace. */ +const DERIVED_SID = 'S-1-4-42-42' + /** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { +function recordEvent(record: { sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } } @@ -72,7 +77,7 @@ function shapedTempPath(): string { return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) } -describe('windows-acl per-session grant (LocalSandboxProvider)', () => { +describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { @@ -85,7 +90,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => { + it('workspace-write: first confine provisions the record and materializes ONCE (standing workspace + revocable private temp)', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() @@ -95,28 +100,40 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(confined.argv).toContain('--write-sid') - expect(confined.argv).toContain('S-1-4-42-42') + expect(confined.argv).toContain(DERIVED_SID) expect(confined.argv).toContain('workspace-write') - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir scratch.push(tempDir) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false }) + expect(mockState.grants[0]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked + disposed: false, + }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: tempDir, standing: false }], + disposed: false, + }) expect(existsSync(tempDir)).toBe(true) // created exclusively expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - // Reuse: the second confine is the map hit. + // Reuse: the second confine is the map hits. sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) expect(session.events).toHaveLength(1) await fiber.dispose() + // dispose() runs on BOTH grants: the standing workspace ACE is left in + // place (the mock marks it disposed only as instance teardown). expect(mockState.grants[0]!.disposed).toBe(true) + expect(mockState.grants[1]!.disposed).toBe(true) } finally { cleanup() } }) - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the same SID, the downgrade keeps the standing grant', async () => { + it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -134,28 +151,33 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { scratch.push(record.tempDir) expect(existsSync(record.tempDir)).toBe(false) - // Upgrade: first workspace-write materializes with the same SID. + // Upgrade: first workspace-write materializes with the derived SID. const upgraded = sandbox.confine(['true'], workspaceWrite) expect(upgraded.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', record.tempDir, '--mode', 'workspace-write', - '--write-sid', 'S-1-4-42-42', + '--write-sid', DERIVED_SID, '--', 'true', ]) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false }) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: record.tempDir, standing: false }], + disposed: false, + }) expect(existsSync(record.tempDir)).toBe(true) - // Reuse: map hit. + // Reuse: map hits. sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) expect(mockState.grants[0]!.disposed).toBe(false) expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) } finally { @@ -177,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { '--workspace', ws, '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing '--mode', 'read-only', - '--write-sid', 'S-1-4-42-42', + '--write-sid', DERIVED_SID, '--', 'true', ]) @@ -188,12 +210,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => { + it('resume: a seeded record replays the same derived SID and temp dir with no second event appended', async () => { try { const ws = workspaceRoot() scratch.push(ws) const tempDir = shapedTempPath() - const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir } + const record = { sessionId: SessionId('resumed'), workspace: ws, tempDir } scratch.push(tempDir) const first = await setup() @@ -202,9 +224,9 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } const confined = first.sandbox.confine(['true'], policy) - expect(confined.argv).toContain('S-1-4-77-1') - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] }) + expect(confined.argv).toContain(DERIVED_SID) // re-derived from the record's workspace + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: tempDir, standing: false }] }) // Replay IS the state: nothing appended. expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) expect(session.events).toHaveLength(2) @@ -213,52 +235,52 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => { + it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh temp identity (sessionId binding)', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) const parentTemp = shapedTempPath() - const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + const parentRecord = { sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } scratch.push(parentTemp) // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's + expect(mockState.grants).toHaveLength(2) + // Fresh temp identity, NOT the parent's (the workspace SID is shared by + // derivation — the workspace is the same). + const childTemp = (child.events.at(-1)!.data as { tempDir: string }).tempDir + expect(childTemp).not.toBe(parentTemp) + expect(mockState.grants[1]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh } finally { cleanup() } }) - it('fails loud on a matching-but-tampered record: non-orphan SID, foreign temp path, and non-string fields never materialize', async () => { + it('fails loud on a matching-but-tampered record: foreign temp path, empty workspace, and non-string fields never materialize', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - // writeSid = Everyone: would widen the grant to every token. - const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() } - ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } }) - const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') } - expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/) - // tempDir outside the host temp root. - const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } + const foreignTemp = { sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) // Non-string durable fields (a corrupted/tampered JSONL payload): the - // typeof guards fail loud before any string operation runs. + // typeof guards fail loud before any string operation runs. There is + // NO stored SID to tamper with — the write SID is derived from the + // workspace path, so the old "SID rewritten to Everyone" attack + // surface does not exist. const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ - { id: 'tampered-type-sid', record: { writeSid: 42, sessionId: SessionId('tampered-type-sid'), workspace: ws, tempDir: shapedTempPath() }, expect: /malformed write SID/ }, - { id: 'tampered-type-ws-null', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-ws-empty', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-temp', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, + { id: 'tampered-type-ws-null', record: { sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-ws-empty', record: { sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, + { id: 'tampered-type-temp', record: { sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, ] for (const c of cases) { ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) @@ -271,7 +293,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => { + it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -281,12 +303,15 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const preexisting = shapedTempPath() mkdirSync(preexisting) scratch.push(preexisting) - const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } + const preRecord = { sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(1) - expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked + // The standing workspace grant is the intended end state and stays; the + // failed temp grant self-disposes. + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) + expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked // Reparse point: same EEXIST (exclusive mkdir never follows links). const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) @@ -294,12 +319,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name symlinkSync(target, linkPath) scratch.push(linkPath) - const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } + const linkRecord = { sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]!.disposed).toBe(true) + expect(mockState.grants).toHaveLength(4) + expect(mockState.grants[3]!.disposed).toBe(true) } finally { cleanup() } @@ -310,7 +335,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const { ctx, sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } + const mismatched = { sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) @@ -336,7 +361,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => { + it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { try { const { ctx, sandbox } = await setup() const ws = workspaceRoot() @@ -344,7 +369,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - // add() throws on the FIRST path: cleanup dispose() runs, original error propagates. + // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) @@ -381,7 +406,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { } }) - it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => { + it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() @@ -390,12 +415,13 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => { const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) - expect(mockState.grants).toHaveLength(1) + expect(mockState.grants).toHaveLength(2) mockState.disposeFailure = new Error('revoke exploded') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await fiber.dispose() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure')) + // BOTH grants (standing workspace + revocable temp) fail their dispose. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failures')) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) } finally { cleanup() diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 3fb0ec6692..7ba547bc10 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 80d0502c3ace71da49e8f40efc6aa379286c74ac -README.zh.md: b0c50af1b339a9fdc206778b6184b8e9121ef19c +README.md: 3185510142750178b11c2f6bad863a0a4861a29c +README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 80d0502c3a..3185510142 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -4,29 +4,30 @@ English | [中文](README.zh.md) Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) whose Write ACEs exist only on the session's workspace and private temp directories (the seam provisions ONE SID per session and materializes the ACEs for the server's lifetime — see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the orphan SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). ## Usage ```ts -import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) +// match the grant shape: read-only pairs with zero grants. workspace-write +// REQUIRES the workspace's write SID — the per-workspace identity. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes all standing grants; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure ``` -A direct `AclSandbox` grants and revokes per instance (one allowlist per spawn cycle). The server-side per-session reuse is the `AclWriteGrant` class: one instance per session, `add()` per directory, `dispose()` on provider shutdown — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner @@ -36,17 +37,17 @@ The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrap node runner.js --workspace --temp --mode [--write-sid ] -- ``` -The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes all grants on exit. Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Per-session grant reuse** (`--write-sid`): the seam provisions ONE orphan SID per session — stored as a log-only `sandbox/acl-session` event on the session log (bound to the owning session id, validated at the fold), so a resumed session replays the SAME SID and a fork mints a fresh one — and materializes its ACEs lazily at the session's first confined execution, holding them for the SERVER process's lifetime (revoked on provider dispose). A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand inert orphan-SID ACEs, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`); without it (standalone use) it self-manages per-call grants as before. Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection: the session's record re-grants the same SID, and the next dispose revokes them. Known cost: materializing a grant on a big workspace tree blocks for the full eager propagation once per session per server lifetime. +**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam still provisions ONE log-only `sandbox/acl-session` event per session (bound to the owning session id, validated at the fold) carrying the session's workspace binding and PRIVATE temp subdirectory: a resumed session replays the same temp dir, a fork mints a fresh one. The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): -- `workspace-write` (logon SID, Everyone, orphan): the workspace and the session's PRIVATE temp subdirectory carry the orphan-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO orphan): STRICT zero grants — nothing is writable. The orphan stays OUT of the list on purpose: a standing grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the unrevoked ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. +- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the per-session contract. +The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. ## Header verification @@ -62,7 +63,7 @@ The koffi struct definitions assert their sizes against the probe at module load - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. -- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; `dispose()` revokes them, and `init()` revokes already-applied grants when a later step fails. The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. The per-session record makes an unclean shutdown self-healing: the same SID is re-granted on resume (skipping the apply when the ACE stands) and revoked at the next dispose; orphan ACEs never accumulate a new SID per restart. +- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. - **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. @@ -78,11 +79,12 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work -- **One write allowlist per instance** — the orphan SID is the unit of the allowlist; reusing one sandbox instance across two workspaces widens both grants to both roots. Create one instance per workspace root (the seam's per-session record does exactly this: one SID per session, keyed to the session's immutable cwd). -- **Cleanup is best-effort by design** — `dispose()` attempts every revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but orphan-SID-only) ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. +- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. -- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-session reuse pays it once per session per server lifetime (lazily at the first confined execution, skipped entirely when the exact ACE survives a restart); the self-managed runner fallback still pays it per invocation. If a session's workspace is huge, the first pwsh call of each server lifetime is correspondingly slow. -- **Resuming one session concurrently in two server processes grants two SIDs.** The durable record lives in the session log; both processes read or provision it independently, the per-path lock keeps the DACL merges consistent, and the last-written record wins for future resumes — the losing SID's ACEs are revoked by its own process's dispose. Single-writer session usage (the normal deployment) never sees this. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. +- **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. - **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index b0c50af1b3..ab45d29c7d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,87 +2,89 @@ [English](README.md) | 中文 -面向 [harness 沙盒接口](../sandbox/) 的 Windows 写入限制沙盒后端:用 Node.js/[koffi](https://koffi.dev/) 移植了 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb` 修复版)的机制,作为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 档(`workspace-write` / `read-only` 模式)挂载;同一包还携带 Linux/macOS 后端。 +面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个孤儿 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于会话的工作区与私有临时目录上(seam 为每个会话只配置一个 SID,并为服务器的生命周期物化 ACE——见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——孤儿 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 -直接基于原始 ACL 机制实现是记录在案的设计选择:它能在不引入两个被否决容器方案所带问题的前提下实现两种限制模式——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 起步的 OS 版本,且任意路径读需要全盘写入宿主 DACL;AppContainer 则根本不支持任意路径读)。 +直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 ## 用法 ```ts -import { AclSandbox } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], mode: 'workspace-write' }) +// match the grant shape: read-only pairs with zero grants. workspace-write +// REQUIRES the workspace's write SID — the per-workspace identity. +const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes all standing grants; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure ``` -直接使用 `AclSandbox` 时按实例授权与回收(每个 spawn 周期一个白名单)。服务器侧的按会话复用是 `AclWriteGrant` 类:每个会话一个实例,每个目录一次 `add()`,提供方关闭时 `dispose()` ——见下方 runner 契约。本包对**每一个** Win32 API 调用都做返回值检查;失败抛出 `Win32Error`,携带 API 名、精确的 Win32 错误码、`FormatMessageW` 系统文本和出错的路径/上下文。这是有意为之:原 POC 忽略所有返回值,当 `CreateRestrictedToken` 失败时会静默地用**完整未受限令牌**运行子进程(fail-open)。本移植从构造上保证 fail-closed。 +直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 ## 隔离 runner -面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 用它替换调用方命令的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约**无需任何改动**。稳定的 argv 契约: +面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: ```sh node runner.js --workspace --temp --mode [--write-sid ] -- ``` -runner 创建受限令牌,在令牌下启动被包裹的 argv,stdio 直接透传(spawn 前后把调用方的管道句柄恢复/清除继承位——Node 启动时会清掉自身 stdio 的继承位,裸 spawn 必须补偿这一点),把子进程放进 `KILL_ON_JOB_CLOSE` 作业(runner 死亡即杀死子进程),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程退出码,退出时回收所有授权。任何 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 据此区分 runner 失败与真正的权限拒绝。 +runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按会话授权复用**(`--write-sid`):seam 为每个会话只配置一个孤儿 SID——以仅作日志记录的 `sandbox/acl-session` 事件写入会话日志(绑定其所属会话 id,在 fold 处校验),因此恢复的会话回放**同一个** SID,fork 则铸造一个新的——并在会话首次受限执行时惰性物化其 ACE,在**服务器**进程生命周期内持有(提供方 dispose 时撤销)。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留失效的孤儿 SID ACE,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`);不传它(独立使用)则与之前一样按调用自行管理授权。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收:会话记录重新授权同一个 SID,下一次 dispose 即撤销它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每个服务器生命周期内每会话一次。 +**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID(先前每会话随机 SID 及其篡改面已移除)。seam 仍会为每个会话只供给一条仅作日志记录的 `sandbox/acl-session` 事件(绑定其所属会话 id,在 fold 处校验),携带会话的工作区绑定与**私有**临时子目录:恢复的会话回放同一个临时目录,fork 则铸造一个新的。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 -模式(令牌的 restricting SID 列表随模式而定;保活组在**两种**模式下都是登录 SID + Everyone——没有它们,早期 DLL init 会以 `0xC0000142` 死亡,CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、孤儿 SID):工作区与会话的**私有**临时子目录携带孤儿 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——不含孤儿 SID):**严格零授权**——没有任何可写位置。孤儿 SID 有意留在列表**之外**:先前 workspace-write 时期留下的驻留授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而未撤销的 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): +- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 -Authenticated Users 在**两种**列表中都缺席——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(静默返回不完整结果而非报错)在**每一种**受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——模型可见面文档化的是这一契约,而非提示词承诺。INTERACTIVE/LOCAL 同样在**两种**列表中都缺席:宿主的 Public 树把写权限授予 INTERACTIVE,因此 Public 写入会被拒绝——由 runner 的环境可写 Public-probe 回归钉住(见设计笔记)。 +Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 -`AclSandbox` 类(`tempDir: null` 关闭临时目录授权)仍是直接 spawn 场景的程序化 API;`AclWriteGrant` 是按会话契约中服务器侧的物化半边。 +`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 -## 头文件查证 +## 头部验证 -所有常量、函数签名和结构体布局都对照开发机的 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)逐一核实,并由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(尺寸、偏移、枚举值、static_assert)交叉验证: +所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查: ```sh g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe ``` -模块加载时 koffi 结构体定义会与探针输出比对尺寸,头文件/koffi 布局一旦漂移立即报错,而不是悄悄写坏内存。 +koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。 -## 已验证的边界(受限令牌固有,非本移植缺陷) +## 已验证边界(受限令牌固有,非本移植引入) -- **只限制写;读、网络、进程可见性均不受限。** `WRITE_RESTRICTED` 只对写访问做交集检查,受限子进程可以读取调用者能读的任何文件、可以开 socket。因此 `read-only` 模式无法仅靠本机制表达,需要叠加读侧策略或改用 AppContainer/`S-1-15-2` capability 令牌做强隔离。 -- **控制台隔离不可用。** 受限令牌下用 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程会在 DLL 初始化阶段以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 曾试图把控制台登录 SID(`S-1-2-1`)加进 restricting 列表来修复:在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 直接失败(`ERROR_INVALID_PARAMETER` 87),改用正确的 `WinConsoleLogonSid` 虽能得到合法的 `S-1-2-1`,子进程仍然死亡,POC 最终版本遂删除了该 SID 并放弃控制台隔离。因此子进程共享宿主控制台;stdio 重定向走管道,不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;`dispose()` 负责回收,`init()` 后续步骤失败时也会回滚已应用的授权。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。按会话记录让异常关闭可自愈:恢复时重新授权同一个 SID(ACE 已存在则跳过应用),并在下一次 dispose 撤销;孤儿 ACE 不会因每次重启而累积新 SID。 -- **被授权目录必须归调用者所有。** 所有者隐含的 `WRITE_DAC` 是免提权改 DACL 的前提。 -- **临时目录授权跟随 `GetTempPathW`** —— 尽可能显式传入 `tempDir`。`GetTempPathW` 读取的是原生环境块,用 worker 池管理 `process.env` 的宿主运行时(vitest 实测)不会把 worker 侧的 `process.env.TMP` 改动同步过去。seam 会传入会话的**私有**子目录(`\dsh-<16 random hex>`,独占创建——已有条目或 reparse point 会响亮失败);若默认授权落到真实临时目录,其 `(OI)(CI)` 继承会覆盖 temp 下所有子目录、静默扩大白名单——请指向按沙盒隔离的目录。 +- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。 +- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 +- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 +- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 +- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 位随机 hex>`,独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 - **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 -- **`whoami` 与令牌检查类 cmdlet 在受限令牌下会失败。** 副本上的 `GetTokenInformation` 对子进程部分不可用,因此 `whoami /all` 会报错——这是受限方案的诊断噪音,而非运行故障;真正重要的拒绝面(文件写入)不受影响。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 -## 模型体验 +## Model Experience -经 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具间接生效:它们渲染本后端的强制完整性与拒绝事实(受限 stderr 由工具层按 `denialSignatures` 分类),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 #### KV Cache 影响 -无直接影响;拒绝呈现面属于工具层。 +无直接影响;拒绝面属于工具层。 -## 已知限制与后续工作 +## Known Limitations and Deferred Work -- **每个实例一个写入白名单** —— 孤儿 SID 是白名单的基本单位;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面。请按工作区根目录各建一个实例(seam 的按会话记录正是这样做的:每个会话一个 SID,以会话不可变的 cwd 为键)。 -- **清理尽力而为** —— `dispose()` 会尝试全部回收并把失败聚合为 `AggregateError`;清理失败只会留下仅含孤儿 SID 的 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 -- **NULL DACL 目录在 grant+revoke 下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,而 revoke 往返之后留下的是**空**(deny-all)DACL,而非原来的 NULL DACL。POC 也有同样行为;真实的工作区与临时目录都带真实 DACL,因此这仍是一条记录在案的边角,而非被守护的路径。 -- **授权物化是急切的全树传播。** 对带可继承 ACE 的目录调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性求值——实测在大型工作区树加上真实临时根上要几十秒)。按会话复用使它在每个服务器生命周期内每会话只付一次(在首次受限执行时惰性发生;完全相同的 ACE 历经重启存活时整体跳过);自管理的 runner 回退路径仍每次调用都付。若会话的工作区巨大,每个服务器生命周期内的第一次 pwsh 调用会相应地变慢。 -- **在两个服务器进程中并发恢复同一会话会产生两个 SID。** 持久化记录存放在会话日志中;两个进程各自读取或创建记录,按路径的锁保持 DACL 合并一致,最后写入的记录胜出并用于后续恢复——落败 SID 的 ACE 由其所属进程的 dispose 撤销。单写者的会话用法(常规部署形态)不会遇到这种情况。 -- **读侧隔离与网络策略超出范围** —— `WRITE_RESTRICTED` 只对写访问做交集检查;更强的隔离需叠加读侧策略。 -- **宽目录与 FAT 卷警告留待后续;FAT 类目标保持可写。** 针对异常宽的目录或 FAT 类(无 ACL)卷授权的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**时只会让授权立即报错(无 ACL 支持)。位于授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查会通过(Everyone 在两种列表中都存在),这类目标在**两种**受限模式下都可写。FAT 视作历史残留——不支持、不工程化应对;这一仅警告性姿态在此记录成文,而非加以缓解。 -- **两种受限模式都以 ConstrainedLanguage 运行 `pwsh`。** 受限令牌触发 PowerShell 的锁定检测,因此在 `read-only` 与 `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射都会以 `Cannot create type` / `Cannot invoke method`(“only core types”)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 会被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问继续工作。`pwsh` 工具描述把这一契约教给模型;`danger-full-access` 调用不受隔离、以 FullLanguage 运行。 +- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 +- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 +- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 +- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 +- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 +- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 +- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7826922f1d..7cfd6e1a36 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -19,16 +19,22 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '. import type { NativePtr, Win32Bindings } from './ffi.ts' /** - * One orphan write SID's server-lifetime grant materialization: the parsed - * SID pointer plus every directory whose DACL currently carries its ACE. - * Create with {@link AclWriteGrant.create}; dispose revokes all. + * One write SID's server-lifetime grant materialization: the parsed SID + * pointer plus every directory whose DACL currently carries its ACE. + * Workspace paths are added STANDING (their ACEs are the cross-session reuse + * cache and outlive the grant — dispose() skips revoking them, or the next + * provision would re-propagate the whole tree); temp paths are revocable + * (dispose() revokes them — an inheritable ACE must not outlive its + * session's temp directory). Create with {@link AclWriteGrant.create}; + * dispose revokes the revocable paths and frees the SID. */ export class AclWriteGrant { - /** The orphan write SID in SDDL string form. */ + /** The write SID in SDDL string form. */ readonly writeSid: string private readonly api: Win32Bindings private readonly sidPtr: NativePtr - private readonly grantedPaths: string[] = [] + private readonly revocablePaths: string[] = [] + private readonly standingPaths: string[] = [] private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { this.api = api @@ -57,28 +63,31 @@ export class AclWriteGrant { /** * Grant the write ACE on one directory (idempotent: an already-standing * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose}. The path is - * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after - * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an - * ungranted path is a no-op merge. Callers treat a throw as a failed - * materialization and dispose the instance to revoke the paths granted so - * far. + * {@link grantWrite}) and record the path for {@link dispose} unless it is + * standing. The path is recorded BEFORE the grant: a post-apply throw (a + * LocalFree failure after SetNamedSecurityInfoW succeeded) must still + * revoke it, and revoking an ungranted path is a no-op merge. Callers + * treat a throw as a failed materialization and dispose the instance to + * revoke the paths granted so far. * @param path - the directory whose DACL gains the grant. + * @param standing - the ACE outlives this grant (the workspace reuse + * cache; dispose() skips revoking it). Default false (revoked on + * dispose — the temp-directory lifecycle). */ - add(path: string): void { - this.grantedPaths.push(path) + add(path: string, standing = false): void { + ;(standing ? this.standingPaths : this.revocablePaths).push(path) grantWrite(this.api, path, this.sidPtr) } /** Every directory currently carrying the grant, in grant order. */ get paths(): readonly string[] { - return this.grantedPaths + return [...this.standingPaths, ...this.revocablePaths] } - /** Revoke every standing grant and free the SID; reports every cleanup failure. */ + /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ dispose(): void { const failures: unknown[] = [] - for (const path of this.grantedPaths) { + for (const path of this.revocablePaths) { try { revokeWrite(this.api, path, this.sidPtr) } catch (error) { diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 93e0e33c69..6180927123 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,17 +2,21 @@ * Windows ACL write-restriction sandbox backend for the DeepSeek Harness * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED - * token whose restricting SIDs include an orphan SID (`S-1-4-x-y`) that only - * this sandbox instance adds to the target directories' DACLs — the - * intersection check then allows writes exactly where that SID has a Write - * ACE, and nowhere else the orphan SID is concerned (the token's write check - * ALSO inherits the ambient write ACEs of the other restricting SIDs — the - * keep-alive group logon SID + Everyone; Authenticated Users, - * INTERACTIVE, and LOCAL are absent from both lists — see the seam's - * dual-list contract in `packages/sandbox/sandbox-local` and the package - * README's Modes section for the complete boundary). Unlike the POC, every - * API failure throws with the API name and exact Win32 code; a child is - * NEVER spawned unrestricted. + * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only + * this sandbox adds to the target directories' DACLs — the intersection + * check then allows writes exactly where that SID has a Write ACE, and + * nowhere else the write SID is concerned (the token's write check ALSO + * inherits the ambient write ACEs of the other restricting SIDs — the + * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, + * and LOCAL are absent from both lists — see the seam's dual-list contract + * in `packages/sandbox/sandbox-local` and the package README's Modes section + * for the complete boundary). The write SID is the per-WORKSPACE identity + * ({@link workspaceWriteSid}): deterministic from the canonical workspace + * path, so the workspace-root ACE materializes once per workspace per + * machine and every later provision hits the exact-ACE skip — the + * grant-reuse story the per-session random SID paid a full tree propagation + * per session for. Unlike the POC, every API failure throws with the API + * name and exact Win32 code; a child is NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): * - writes are restricted; reads, network, and process visibility are NOT @@ -22,17 +26,19 @@ * STATUS_DLL_INIT_FAILED under the restriction); * - the temp directory and every writable directory must be owned by the * caller (owner-implicit WRITE_DAC); - * - grants are standing ACE mutations on real directories — revoke them via - * dispose() before the process exits (the POC's documented - * `icacls /remove '*S-1-4-…'` cleanup fails with ERROR_NONE_MAPPED; use - * this module's revoke instead). With `manageDacls: false` the CALLER owns - * the DACLs (the sandbox seam's per-session grant reuse): init()/dispose() - * skip grant/revoke entirely and the caller must not revoke under live - * children. + * - grants are standing ACE mutations on real directories. WORKSPACE grants + * are deliberately never revoked — the ACE is the cross-session reuse + * cache (revoking would force the next session to re-propagate the whole + * tree). TEMP grants are revocable: dispose() removes them so a standing + * inheritable ACE never outlives its session's temp directory (an + * inheritable ACE on the ambient temp root would otherwise widen the + * SID's write reach to every future temp file). With `manageDacls: false` + * the CALLER owns the DACLs (the sandbox seam's grant reuse): + * init()/dispose() skip grant/revoke entirely and the caller must not + * revoke under live children. * @module @deepseek-ai/dsh-sandbox-windows-acl */ -import { randomInt } from 'node:crypto' import { existsSync, statSync } from 'node:fs' import { resolve } from 'node:path' @@ -46,6 +52,7 @@ import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' +export { workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' /** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ @@ -58,7 +65,13 @@ export interface AclSandboxOptions { * allowance — not even the NUL device is writable, see README). */ tempDir?: string | null - /** Orphan write SID; defaults to a random `S-1-4-x-y` (fresh allowlist per sandbox). */ + /** + * The write SID forming the workspace-write allowlist: REQUIRED under + * workspace-write, ignored (and must be absent) under read-only. Callers + * derive it from the workspace via {@link workspaceWriteSid} — the identity + * is per workspace, not per sandbox instance, so the workspace-root ACE + * outlives every instance and later provisions hit the exact-ACE skip. + */ writeSid?: string /** * The file-effect mode this instance confines under — selects the @@ -109,25 +122,20 @@ export interface AclSandboxChild { wait(): Promise } -/** Mint a fresh orphan write SID (`S-1-4-x-y`; the subauthorities are 30-bit). - * @returns the SDDL string form. - */ -export function randomWriteSid(): string { - return `S-1-4-${randomInt(1, 2 ** 30)}-${randomInt(1, 2 ** 30)}` -} - /** - * One write-restricted sandbox instance: token + orphan-SID grants + spawn. - * `init()` is fail-closed — any Win32 failure revokes whatever was granted - * and throws; `dispose()` revokes all grants and reports every cleanup - * failure. With `manageDacls: false` the caller owns the grants (per-session + * One write-restricted sandbox instance: token + write-SID grants + spawn. + * `init()` is fail-closed — any Win32 failure revokes the revocable (temp) + * grants and throws; `dispose()` revokes the temp grants, leaves the + * standing workspace ACEs in place (the cross-instance reuse cache), frees + * every allocation, and reports every cleanup failure. With + * `manageDacls: false` the caller owns the grants (the sandbox seam's grant * reuse): init() applies none and dispose() revokes none. */ export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The orphan SID string whose ACEs form the write allowlist. */ - readonly writeSid: string + /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + readonly writeSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined @@ -151,7 +159,10 @@ export class AclSandbox { return absolute }) this.tempDirOption = options.tempDir - this.writeSid = options.writeSid ?? randomWriteSid() + this.writeSid = options.writeSid + if (this.mode === 'workspace-write' && this.writeSid === undefined) { + throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()') + } } /** Resolved temp directory (available after init; null when temp grants are disabled). */ @@ -166,14 +177,19 @@ export class AclSandbox { const currentToken = openCurrentProcessToken(api) try { - const sidSlot = allocPtrSlot() - if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { - throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + // Read-only runs carry no write SID (its restricting list has no + // orphan): nothing to parse, nothing to grant. + let writeSidPtr: NativePtr | undefined + if (this.writeSid !== undefined) { + const sidSlot = allocPtrSlot() + if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + } + const parsedSid = decodePtr(sidSlot) + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) + this.writeSidPtr = parsedSid + writeSidPtr = parsedSid } - const parsedSid = decodePtr(sidSlot) - if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) - this.writeSidPtr = parsedSid - const writeSidPtr = parsedSid const tempDir = this.tempDirOption === null ? null @@ -185,16 +201,26 @@ export class AclSandbox { this.tempDirResolved = tempDir } - // manageDacls: false — the caller (the sandbox seam's per-session grant) - // already materialized the ACEs; this instance must neither add nor - // remove any (its dispose() must not revoke the caller's standing grant). + // manageDacls: false — the caller (the sandbox seam's grant) already + // materialized the ACEs; this instance must neither add nor remove any. + // When this instance owns the DACLs, writableDir ACEs are STANDING (the + // per-workspace reuse cache — dispose() never revokes them, or the next + // provision would re-propagate the whole tree) and the temp ACE is + // REVOCABLE (dispose() removes it — an inheritable ACE on the ambient + // temp root must not outlive the instance, or it would widen the SID's + // write reach to every future temp file). if (this.manageDacls) { - for (const path of tempDir !== null ? [...this.writableDirs, tempDir] : this.writableDirs) { - // Record BEFORE granting: grantWrite can throw after a successful - // apply (a LocalFree failure), and the fail-closed catch must still - // revoke that path (revoking an ungranted path is a no-op merge). - this.grantedPaths.push(path) - grantWrite(api, path, writeSidPtr) + if (writeSidPtr !== undefined) { + for (const path of this.writableDirs) { + grantWrite(api, path, writeSidPtr) + } + if (tempDir !== null) { + // Record BEFORE granting: grantWrite can throw after a successful + // apply (a LocalFree failure), and the fail-closed catch must still + // revoke that path (revoking an ungranted path is a no-op merge). + this.grantedPaths.push(tempDir) + grantWrite(api, tempDir, writeSidPtr) + } } } const logonSid = findLogonSid(api, currentToken) @@ -212,8 +238,10 @@ export class AclSandbox { } catch (error) { // Best-effort close on the failure path (last error already captured in `error`). api.closeHandle(currentToken) - // Fail-closed cleanup: never leave standing grants or SID allocations - // behind a failed init. + // Fail-closed cleanup: never leave a revocable (temp) grant or SID + // allocation behind a failed init. Standing workspace ACEs are NOT + // revoked — they are the intended end state (the reuse cache), not an + // error artifact. const cleanupFailures: unknown[] = [] const writeSidPtr = this.writeSidPtr if (writeSidPtr !== undefined) { @@ -293,7 +321,11 @@ export class AclSandbox { } } - /** Revoke all standing grants, free the SID, close the token; reports every cleanup failure. */ + /** + * Revoke the revocable (temp) grants, free the SID, close the token; the + * standing workspace ACEs stay (the reuse cache). Reports every cleanup + * failure. + */ dispose(): void { const api = this.api if (api === undefined) return diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 3487e182be..93f8cfcc01 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -1,9 +1,10 @@ /** * The windows-acl confinement runner: the argv-prefix wrapper the sandbox * seam spawns in place of the caller's command. It creates the - * WRITE_RESTRICTED token with the orphan-SID allowlist, spawns the wrapped - * argv under it with the CALLER'S stdio inherited (bytes flow straight - * through), mirrors the child's exit code, and revokes all grants on exit. + * WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the + * wrapped argv under it with the CALLER'S stdio inherited (bytes flow + * straight through), mirrors the child's exit code, and revokes its temp + * grant on exit (workspace ACEs stay standing as the reuse cache). * * Stable argv contract (the seam builds it; a native-exe replacement would * keep the same contract): @@ -22,19 +23,23 @@ * Public tree writes are denied); the two lists share the keep-alive group * (logon SID, EVERYONE) and differ only by the orphan. * - * `--write-sid`: the seam's per-session grant contract — the CALLER has - * already materialized the orphan-SID ACEs (once per session, server - * lifetime) and owns their revocation, so the runner neither grants nor - * revokes (manageDacls: false). Absent `--write-sid` (standalone/test use) - * the runner self-manages grants per invocation as before. With - * `--write-sid` in workspace-write mode, the runner rewrites the TMP/TEMP - * entries of its OWN environment (SetEnvironmentVariableW) to the `--temp` - * directory — a PRIVATE per-session temp subdirectory the seam provisions - * (bwrap `--tmpfs /tmp` semantics) — and the child inherits the rewritten - * block (lpEnvironment NULL; an explicit block through koffi trips - * ERROR_INVALID_PARAMETER in CreateProcessAsUserW, verified empirically). - * Read-only leaves the ambient temp entries untouched (writes there are - * denied anyway). + * `--write-sid`: the seam's grant contract — the CALLER has already + * materialized the write-SID ACEs (the seam's workspace + private-temp + * grants, server lifetime) and owns their revocation, so the runner neither + * grants nor revokes (manageDacls: false). The carried SID is the + * per-workspace identity ({@link workspaceWriteSid}) — the seam derives it + * from the policy root; the flag's PRESENCE is the seam-managed marker (its + * value must equal the workspace-derived SID). Absent `--write-sid` + * (standalone/test use) the runner self-manages grants per invocation with + * the same workspace-derived SID (its workspace ACEs are standing — the + * reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in + * workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN + * environment (SetEnvironmentVariableW) to the `--temp` directory — a + * PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs + * /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment + * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in + * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient + * temp entries untouched (writes there are denied anyway). * * Failure contract: every runner-side failure (bad args, missing * directories, token/grant/spawn errors) prints `windows-acl-run: ` @@ -47,6 +52,7 @@ import { existsSync, statSync } from 'node:fs' import { win32 } from './ffi.ts' import { AclSandbox } from './index.ts' +import { workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -121,13 +127,17 @@ async function main(): Promise { fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) } + // The write SID is the per-workspace identity in BOTH flows; the flag's + // presence (seam-derived, or the self-managed derivation) selects who + // owns the DACLs below. + const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined const sandbox = new AclSandbox({ writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, mode: parsed.mode, - ...parsed.writeSid === undefined ? {} : { writeSid: parsed.writeSid }, - // With --write-sid the seam owns the DACLs (per-session grants): this - // invocation must neither add nor revoke ACEs. + ...writeSid === undefined ? {} : { writeSid }, + // With --write-sid the seam owns the DACLs (workspace + private-temp + // grants): this invocation must neither add nor revoke ACEs. manageDacls: parsed.writeSid === undefined, }) await sandbox.init() diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index bd0f71ee9f..b0f9ea8f45 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -114,13 +114,13 @@ export interface RestrictingSidSet { * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — - * pwsh crashes 0xE0434352) fails without them. The orphan SID joins ONLY - * workspace-write — read-only carries no orphan, so a standing grant ACE + * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY + * workspace-write — read-only carries no write SID, so a standing grant ACE * from an earlier workspace-write period (a `/permission` mode downgrade, or * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED * pass-2 check grants only what the restricting list carries, keeping * read-only strictly zero-grant even with stale ACEs standing, while the - * unrevoked ACE keeps the re-upgrade free (the seam's grant map hits it — no + * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no * re-propagation). Authenticated Users is absent from BOTH lists: the WMI * namespace security check fails (0x80041003), so CIM is unavailable in * every confined mode, and the C:\-root tree-creation escape (standing @@ -133,22 +133,24 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the orphan SID forming the write allowlist (workspace-write only). + * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (workspace-write adds the orphan). + * @param mode - selects the restricting list (workspace-write adds the write SID). * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, currentToken: NativePtr, logonSid: NativePtr, - writeSid: NativePtr, + writeSid: NativePtr | undefined, known: RestrictingSidSet, mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : [logonSid, known.world, writeSid]) + : writeSid === undefined + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() + : [logonSid, known.world, writeSid]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts new file mode 100644 index 0000000000..db74893f36 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -0,0 +1,38 @@ +/** + * The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived + * from the canonical workspace path, whose ACEs form that workspace's write + * allowlist. Every confined execution of the same workspace — across + * sessions, server restarts, and calls — carries the SAME write SID, so the + * workspace-root ACE materializes once per workspace per machine (the + * grant's exact-ACE skip then makes every later provision O(1)) instead of + * once per session. The SID's power is defined solely by the ACEs that name + * it (which exist only on the workspace tree and the session's private temp + * directory), and only tokens minted for that workspace carry it — the SID + * string itself is not a secret (the previous per-session SID was likewise + * logged in the plain). + * + * The input MUST be the canonical workspace path (`realpathSync.native` on + * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): + * canonicalization converges case/alias spellings, so two spellings of one + * workspace derive one SID; an as-spelled fallback path would mint a second + * identity for the same directory (self-healing, at the cost of one extra + * tree propagation). Renaming the workspace directory derives a new SID — + * the old standing ACEs are inert residue, and the next session re-propagates + * once. + * @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid + */ + +import { createHash } from 'node:crypto' + +/** + * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, + * matching the orphan shape the token and ACE layers already carry). + * @param workspaceRoot - the canonical workspace path. + * @returns the SDDL string form. + */ +export function workspaceWriteSid(workspaceRoot: string): string { + const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest() + const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1 + const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 + return `S-1-4-${first}-${second}` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 2e4c5baab7..25abe14392 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -17,6 +17,7 @@ import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' import { AclSandbox } from '../src/index.ts' +import { createRestrictedToken } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' @@ -170,18 +171,42 @@ describe.skipIf(!isWin32)('ACL editing', () => { } }) - it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => { + it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => { const api = await win32() const dir = scratch() const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) await sandboxA.init() await sandboxB.init() + // Workspace ACEs are STANDING: dispose frees the instance's SID + // allocations but deliberately leaves the ACEs — they are the reuse + // cache the next provision's exact-ACE skip consumes. sandboxA.dispose() sandboxB.dispose() const aces = readDirectAces(api, dir) - expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(false) - expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(false) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true) + expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) + }) + + it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + const api = await win32() + const workspaceDir = scratch() + const tempDir = scratch() + const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + await sandbox.init() + sandbox.dispose() + const workspaceAces = readDirectAces(api, workspaceDir) + expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) + const tempAces = readDirectAces(api, tempDir) + expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false) + }) + + it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { + const dir = scratch() + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) + .toThrow(/requires a write SID/) + expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write')) + .toThrow(/requires the write SID/) }) it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts index 02090e2fe9..43810249ab 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant.spec.ts @@ -1,8 +1,10 @@ /** - * AclWriteGrant tests: the server-side per-session grant materialization — - * SID parsing fail-closed, ACE add/dispose round-trip against the REAL - * directory DACL (observed through icacls, the operator's own tool), and - * the recorded path order. Win32-only, like the other real-FFI suites. + * AclWriteGrant tests: the server-side grant materialization — SID parsing + * fail-closed, ACE add/dispose round-trip against the REAL directory DACL + * (observed through icacls, the operator's own tool), the recorded path + * order, and the standing/revocable lifecycle split (workspace ACEs outlive + * dispose as the reuse cache; temp ACEs revoke). Win32-only, like the other + * real-FFI suites. */ import { spawnSync } from 'node:child_process' @@ -38,18 +40,24 @@ describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => { expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u) }) - it('add materializes the ACE (idempotently), paths report the grant order, dispose revokes it', () => { + it('add materializes the ACE (idempotently) and reports grant order; dispose revokes revocable paths and keeps standing paths standing', () => { const dir = scratch() + const standingDir = scratch() const grant = AclWriteGrant.create('S-1-4-9000-77') - grant.add(dir) - expect(grant.paths).toEqual([dir]) + grant.add(dir) // revocable: the session-temp lifecycle + grant.add(standingDir, true) // standing: the workspace reuse cache + expect(grant.paths).toEqual([standingDir, dir]) expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') // A second add over the standing exact ACE is a DACL-read no-op: the - // grant stays exactly one ACE (per-session reuse after a restart). + // grant stays exactly one ACE (the reuse across sessions/restarts). grant.add(dir) + grant.add(standingDir, true) expect(icaclsText(dir)).toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') grant.dispose() expect(icaclsText(dir)).not.toContain('S-1-4-9000-77') + expect(icaclsText(standingDir)).toContain('S-1-4-9000-77') }) it('two grants with different SIDs coexist and revoke independently', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 0eb78e16f4..0825695438 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -51,7 +51,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () // block, which host runtimes (vitest worker pools) may not keep in sync // with process.env — and a real-temp grant would inherit over every // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, mode: 'workspace-write' }) + sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) await sandbox.init() }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts new file mode 100644 index 0000000000..4d24c6f8fb --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -0,0 +1,30 @@ +/** + * workspaceWriteSid tests: the per-workspace write identity is deterministic + * (the same canonical path always derives the same SID — the property the + * cross-session grant reuse rests on), orphan-shaped, distinct across + * workspaces, and byte-sensitive (the canonical path is the caller's + * contract; an alias spelling derives a second identity, self-healing at + * the cost of one extra tree propagation). + */ + +import { describe, expect, it } from 'vitest' + +import { workspaceWriteSid } from '../src/index.ts' + +describe('workspaceWriteSid', () => { + it('derives a stable orphan-shaped SID per workspace path', () => { + const first = workspaceWriteSid('C:\\Users\\agent\\repo') + const second = workspaceWriteSid('C:\\Users\\agent\\repo') + expect(first).toBe(second) + expect(first).toMatch(/^S-1-4-\d+-\d+$/u) + }) + + it('derives distinct identities for distinct workspaces', () => { + expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b')) + }) + + it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => { + expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo')) + expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) + }) +}) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 6c2397a2e0..4510e9a69a 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -44,8 +44,9 @@ export interface SandboxExecutionPolicy { /** * Opaque identity of the calling session (the branded `dsh-session` * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session write grant and private temp subdirectory); absent for - * agentless calls, which fall back to per-call backend state. + * per-session private temp subdirectory — the write grant itself is + * per-workspace, derived from the workspace root); absent for agentless + * calls, which fall back to per-call backend state. */ sessionId?: SessionId } From 8119bc34015c399c69a23fec5af63c8a4d45866f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 10:51:02 +0800 Subject: [PATCH 48/81] test(sandbox): fix grant-count and dispose-warning assertions The reparse case reuses the standing workspace grant of the preceding case (same workspace -> map hit), so the failed temp grant is the third grant, not the fourth; the dispose-warning text carries 'failure(s)'. --- packages/sandbox/sandbox-local/tests/acl-session.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index ff04b97360..560b16e865 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -323,8 +323,10 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - expect(mockState.grants).toHaveLength(4) - expect(mockState.grants[3]!.disposed).toBe(true) + // Same workspace as the preexisting case: the standing workspace grant + // is the map hit (not recreated) — only the failed temp grant joins. + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[2]!.disposed).toBe(true) } finally { cleanup() } @@ -421,7 +423,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) await fiber.dispose() // BOTH grants (standing workspace + revocable temp) fail their dispose. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failures')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)')) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) } finally { cleanup() From bb507830028899923328d7ba779754ef0cac8a53 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 10:57:01 +0800 Subject: [PATCH 49/81] test(sandbox): cover the temp-grant cleanup-failure AggregateError path --- .../sandbox-local/tests/acl-session.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts index 560b16e865..c6ee33f3e8 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-session.spec.ts @@ -327,6 +327,20 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { // is the map hit (not recreated) — only the failed temp grant joins. expect(mockState.grants).toHaveLength(3) expect(mockState.grants[2]!.disposed).toBe(true) + + // Temp-side cleanup failure: the standing workspace grant stays (map + // hit), the exclusive mkdir fails, AND the temp grant's dispose also + // fails — the temp cleanup AggregateError propagates. + mockState.grants = [] + mockState.disposeFailure = new Error('temp cleanup exploded') + const dupTemp = shapedTempPath().replace(/abab$/, 'efef') + mkdirSync(dupTemp) + scratch.push(dupTemp) + const dupRecord = { sessionId: SessionId('temp-cleanup-fail'), workspace: ws, tempDir: dupTemp } + ctx.sessions.create(SessionId('temp-cleanup-fail'), { seed: [recordEvent(dupRecord)], meta: { cwd: ws } }) + const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } + expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) + expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) } finally { cleanup() } From 01a3b454f6cfefd3770cf409755fcad4f6051edb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 12:34:48 +0800 Subject: [PATCH 50/81] fix(sandbox): extend the restricted token's default DACL with a write-SID ACE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New objects created without an explicit security descriptor take their DACL from the token's default DACL, which CreateRestrictedToken builds from the user's ambient SIDs — none of them a restricting SID. Confined children therefore failed the write pass-2 check when creating anonymous pipes (CreatePipe: ERROR_ACCESS_DENIED, surfaced as Node EPERM), breaking PowerShell pipelines and other CreatePipe consumers. Merge a full-access write-SID ACE (Everyone under read-only) into the token default DACL at init via SetTokenInformation(TokenDefaultDacl). Named pipes are EXEMPT: their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which no token change influences, so libuv's piped stdio capture stays denied for confined grandchildren — the POC-documented boundary, now pinned by the runner suite (inherit/ignore OK, pipe DENIED) and documented in the README pair. The NUL paragraph is corrected to the measured matrix (Everyone has 0x1201BF on the device: cmd/node writes land; Set-Content fails at the PS layer). --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 2 +- ...windows-acl-restricted-token-sandbox.zh.md | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 3 +- .../sandbox/sandbox-windows-acl/README.zh.md | 3 +- .../sandbox/sandbox-windows-acl/src/ffi.ts | 2 + .../sandbox/sandbox-windows-acl/src/index.ts | 17 ++++-- .../sandbox/sandbox-windows-acl/src/token.ts | 52 +++++++++++++++++++ .../sandbox-windows-acl/src/win32-abi.ts | 11 ++++ .../sandbox-windows-acl/tests/runner.spec.ts | 31 +++++++++++ 11 files changed, 120 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 98d1f7f653..d11e67f811 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 54468fa46f7dbf7bcb7f765b6ca6bb7ed962e7d2 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10a412a2cb4f584443ca50436ff1baf65141ac1e +2026-08-08-windows-acl-restricted-token-sandbox.md: adbec6f733dcf2903e11b70726a07a9aae211fec +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e35f4f0a20e7f58e24a8ad47964f43d6c9c43681 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 54468fa46f..adbec6f733 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 10a412a2cb..e35f4f0a20 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权、Everyone 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 7ba547bc10..127b03c26d 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 3185510142750178b11c2f6bad863a0a4861a29c -README.zh.md: ab45d29c7dea79c8a75ac2187140e5926cf71581 +README.md: 835b7dace0baf10eec78b35172109a37688d99c2 +README.zh.md: 82a890d75f857b766c7ae47cef7a374edb5ecd63 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 3185510142..835b7dace0 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -43,7 +43,7 @@ The runner creates the restricted token, spawns the wrapped argv under it with t Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. The NUL device is a securable object and is NOT granted (unlike Linux's `/dev/null` sink): `Set-Content NUL` and native `> NUL` writes fail with access denied, while PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). @@ -83,6 +83,7 @@ None directly; the denial surface belongs to the tool layer. - **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. +- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes takes the kernel's PUBLIC default SD template (owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only) — NOT the token default DACL — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. - **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index ab45d29c7d..82a890d75f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -43,7 +43,7 @@ runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 设备是带安全描述符的对象,同样不被授权(区别于 Linux 的 `/dev/null` sink):`Set-Content NUL` 与原生 `> NUL` 写会以 access denied 失败,而 PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 @@ -83,6 +83,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 +- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读)——**不是**令牌默认 DACL——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index 309fe8fe50..99b3cfaff3 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -76,6 +76,7 @@ export interface Win32Bindings { copySid(length: number, destination: NativePtr, source: NativePtr): number // ---- token information --------------------------------------------------- getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number + setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number // ---- restricted token ---------------------------------------------------- createRestrictedToken( existing: NativePtr, flags: number, @@ -392,6 +393,7 @@ function bindings(): Win32Bindings { getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]), copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]), getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]), + setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']), createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]), setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]), diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 6180927123..efa966a441 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -47,7 +47,7 @@ import { Win32Error } from './errors.ts' import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' -import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken } from './token.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' @@ -61,8 +61,9 @@ export interface AclSandboxOptions { writableDirs: readonly string[] /** * Temp directory to also grant; defaults to GetTempPathW() at init time. - * Pass null for read-only confinement: NO temp grant (strict zero write - * allowance — not even the NUL device is writable, see README). + * Pass null for read-only confinement: NO temp grant (strict zero grant on + * the filesystem; the NUL device stays ambient-writable via Everyone — see + * README). */ tempDir?: string | null /** @@ -232,6 +233,16 @@ export class AclSandbox { { world: worldSid }, this.mode, ) + // The restricted token's default DACL still names only the user's + // ambient SIDs — none of the restricting SIDs. Every NEW object the + // confined process creates (anonymous stdio pipes, sync objects) takes + // its DACL from that default, so the write pass-2 check would deny + // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every + // piped-stdio grandchild spawn. Merge a full-access ACE for a + // restricting SID (the write SID under workspace-write, Everyone under + // read-only): new-object creation stays gated by the parent object's + // DACL, while the new object's own DACL passes pass-2. + setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid) this.token = restricted if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') this.api = api diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index b0f9ea8f45..e6254acc03 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -9,6 +9,7 @@ import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { buildExplicitAccess } from './acl.ts' import * as abi from './win32-abi.ts' /** @@ -92,6 +93,57 @@ export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr { return sid } +/** + * Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL + * — the DACL every NEW object the token holder creates (without an explicit + * security descriptor) takes. The restricted token inherits the user's + * default DACL verbatim, which names no restricting SID: a new anonymous pipe + * (child stdio) therefore fails the write pass-2 check at creation + * (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every + * piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the + * write SID under workspace-write, Everyone under read-only), so each new + * object's own DACL passes pass-2 while object creation itself stays gated by + * the parent container's DACL (files outside the granted trees remain + * uncreatable). Fails closed: any Win32 failure throws before the spawn. + * @param api - the binding table. + * @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT). + * @param sidPtr - the restricting SID whose full-access ACE joins the default DACL. + */ +export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void { + const neededSlot = allocUint32() + api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER + const needed = decodeUint32(neededSlot) + if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query') + const buffer = Buffer.alloc(needed) + if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) { + throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl') + } + const currentDacl = decodePtrAt(buffer, 0) + if (currentDacl === null) { + throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend') + } + const newDaclSlot = allocPtrSlot() + const result = api.setEntriesInAclW( + 1, + buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS), + currentDacl, + newDaclSlot, + ) + if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge') + const newDacl = decodePtr(newDaclSlot) + if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL') + // TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the + // pointer; SetTokenInformation copies the ACL before returning. + const info = Buffer.alloc(8) + info.writeBigUInt64LE(newDacl, 0) + if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) { + const win32Code = api.getLastError() + api.localFree(newDacl) + throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl') + } + api.localFree(newDacl) +} + /** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index cc250c2f9e..8e85eced3c 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -70,6 +70,15 @@ export const FILE_DELETE_CHILD = 0x0040 */ export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156 +/** + * FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE + * | 0x1FF): full file-object access. The mask of the ACE merged into the + * restricted token's DEFAULT DACL — the token holder must keep full access to + * every NEW object it creates (pipes included), and the ACE must name a + * restricting SID so the write pass-2 check passes at creation. + */ +export const FILE_ALL_ACCESS = 0x1F01FF + // CreateRestrictedToken flags (winnt.h lines ~4284) /** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */ export const DISABLE_MAX_PRIVILEGE = 0x1 @@ -85,6 +94,8 @@ export const WinWorldSid = 1 // TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2) /** TokenGroups: GetTokenInformation class returning the token's group SIDs. */ export const TokenGroups = 2 +/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */ +export const TokenDefaultDacl = 6 // SECURITY_INFORMATION (winnt.h line ~4293) /** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 2ce251c308..bc64ad11de 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -189,6 +189,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { + // Two-layer pin of the grandchild-spawn boundary: + // - the token default DACL carries a restricting-SID ACE (set in init), + // so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL + // consumer) works and inherited/ignored stdio spawns succeed; + // - libuv's pipe-stdio uses NAMED pipes, whose default security + // descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins + // full, Everyone read-only) — NOT the token default DACL — so the + // client-end open requests write access no restricting SID is + // granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the + // POC-documented "no output redirection" boundary of WRITE_RESTRICTED + // tokens; piped capture cannot work and is pinned as DENIED. + const probe = [ + "const { spawnSync } = require('child_process');", + "const t = (name, opts) => { const s = spawnSync(process.execPath, ['-e', '1'], { encoding: 'utf8', ...opts }); console.log(name + ':' + (s.status === 0 ? 'OK' : 'DENIED')); };", + "t('inherit', { stdio: 'inherit' });", + "t('ignore', { stdio: 'ignore' });", + "t('pipe', { stdio: 'pipe' });", + ].join('') + for (const mode of ['workspace-write', 'read-only'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'node', '-e', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('inherit:OK') + expect(result.stdout, `mode: ${mode}`).toContain('ignore:OK') + expect(result.stdout, `mode: ${mode}`).toContain('pipe:DENIED') + } + }, 30_000) + it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => { // The reported defect: a session that materialized its grant in // workspace-write keeps the ACE standing for the server lifetime. After From 8f2c7c5047d8b81cec9763627f0af6104e9a4eef Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 16:34:09 +0800 Subject: [PATCH 51/81] docs(sandbox): attribute the named-pipe default SD to the Win32-layer user-mode template CreateNamedPipeW with NULL security attributes does not install a kernel template: KernelBase builds the documented 5-ACE default SD in user mode and passes it down; the kernel itself (a raw SD-null create) applies the token default DACL. Correct the claim in both README sides and the runner.spec pin comment, and link the MS template documentation. --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 +- packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts | 8 +++++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 127b03c26d..57ce89dba1 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 835b7dace0baf10eec78b35172109a37688d99c2 -README.zh.md: 82a890d75f857b766c7ae47cef7a374edb5ecd63 +README.md: 6f649f5dcc4ecf5c2cbbe90a687af25e0c6ecc9d +README.zh.md: e688218b2c12a529981433777a1163e3648c918a diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 835b7dace0..6f649f5dcc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -83,7 +83,7 @@ None directly; the denial surface belongs to the tool layer. - **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. -- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes takes the kernel's PUBLIC default SD template (owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only) — NOT the token default DACL — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. +- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. - **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 82a890d75f..e688218b2c 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -83,7 +83,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 -- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读)——**不是**令牌默认 DACL——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 +- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index bc64ad11de..30229d6206 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -195,9 +195,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL // consumer) works and inherited/ignored stdio spawns succeed; // - libuv's pipe-stdio uses NAMED pipes, whose default security - // descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins - // full, Everyone read-only) — NOT the token default DACL — so the - // client-end open requests write access no restricting SID is + // descriptor is the Win32 layer's user-mode default SD template + // (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS + // read-only) — NOT the token default DACL, which is what the kernel + // applies to a raw SD-null create — so the client-end open requests + // write access no restricting SID is // granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the // POC-documented "no output redirection" boundary of WRITE_RESTRICTED // tokens; piped capture cannot work and is pinned as DENIED. From 33e4c6580fe428ab14d1e30ba3a679a2febe77da Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 16:34:25 +0800 Subject: [PATCH 52/81] feat(tool-pwsh): teach the confined named-pipe capture boundary to the model Under read-only and workspace-write the Windows ACL sandbox leaves programs unable to open named pipes, so a piped-stdio spawn fails with EPERM. State that boundary in the pwsh tool description next to the ConstrainedLanguage contract, pin it in the tool tests, and bring the package README and both implemented Agent Notes current with it. --- ...026-08-01-pwsh-tool-and-executor.i18n.yaml | 4 ++-- .../2026-08-01-pwsh-tool-and-executor.md | 4 ++-- .../2026-08-01-pwsh-tool-and-executor.zh.md | 4 ++-- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 ++-- ...08-windows-acl-restricted-token-sandbox.md | 4 ++-- ...windows-acl-restricted-token-sandbox.zh.md | 4 ++-- packages/bash/tool-pwsh/README.i18n.yaml | 4 ++-- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/bash/tool-pwsh/src/index.ts | 19 +++++++++++++------ packages/bash/tool-pwsh/tests/tools.spec.ts | 7 +++++-- 11 files changed, 34 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml index 45c2df39d0..3594c1f5d0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md -2026-08-01-pwsh-tool-and-executor.md: a511035d959ed873d75af87e9f56374dd4cd4f27 -2026-08-01-pwsh-tool-and-executor.zh.md: c0e6a73228e08f2b5f2d5a6ccfb9a6eecce3ba7d +2026-08-01-pwsh-tool-and-executor.md: 855d8e5db8a78e7e798061724d89fde31b28e975 +2026-08-01-pwsh-tool-and-executor.zh.md: a29967d628640eafd2362b74528ee14172f19376 diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md index a511035d95..855d8e5db8 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md @@ -13,7 +13,7 @@ The harness spoke one shell dialect on every platform: `bash`. Windows hosts cou Two new packages under `packages/bash/`: - **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH. -- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus a Windows-specific ConstrainedLanguage contract in the tool description. The parity decision supersedes this note's minimal-profile tool description. +- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus the Windows-specific ConstrainedLanguage and named-pipe contracts in the tool description. The parity decision supersedes this note's minimal-profile tool description. Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines. @@ -30,6 +30,6 @@ The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash of ## Consequences - The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims. -- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage boundary precisely. +- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage and named-pipe boundaries precisely. - Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize. - The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal. diff --git a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md index c0e6a73228..a29967d628 100644 --- a/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md @@ -13,7 +13,7 @@ harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能 在 `packages/bash/` 下新增两个包: - **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。 -- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 契约。parity 决策取代了本 note 的最小画像工具描述。 +- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,bash 的 marker/截断渲染故事(干净退出不产生 marker),以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 与 named-pipe 契约。parity 决策取代了本 note 的最小画像工具描述。 Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。 @@ -30,6 +30,6 @@ Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道 ## 后果 - bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。 -- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 契约、沙箱拒绝/升级词汇与 ConstrainedLanguage 边界。 +- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台、后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 契约、沙箱拒绝/升级词汇,以及 ConstrainedLanguage 与 named-pipe 边界。 - Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。 - CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index d11e67f811..f2514c3cb7 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: adbec6f733dcf2903e11b70726a07a9aae211fec -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: e35f4f0a20e7f58e24a8ad47964f43d6c9c43681 +2026-08-08-windows-acl-restricted-token-sandbox.md: d6915cadb5817679749474c1046d1c715adc2b9b +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 1533e39bf5c239730b6e027e5f0b345d4c6c42e4 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index adbec6f733..d6915cadb5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index e35f4f0a20..1533e39bf5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index fa9af8edce..063985278f 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md -README.md: c0c253fe24bd7eaa4f4d814141e17a67f10f4fb8 -README.zh.md: 7e672bfb4fbda8f683cab3dbb018624d43b2a6d2 +README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8 +README.zh.md: bc0d6ca6099a729db56b7e0de9dfa23a0a6190b8 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index c0c253fe24..3fd5a53946 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **ConstrainedLanguage under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The tool description teaches this contract to the model; the backend README owns the full limitation. +- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 7e672bfb4f..bc0d6ca609 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **Windows sandbox 下的 ConstrainedLanguage** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。工具描述把这一契约教给模型;后端 README 负责完整的限制说明。 +- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个契约教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index fb07683c96..dcf60999b7 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -114,16 +114,23 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base - // The CLM contract below is Windows-restricted-token behavior, but the gate - // is 'any confining executor is mounted' (escalationModes non-empty). The - // conflation is safe today because every shipped composition pairing - // tool-pwsh with a confining executor is win32-only; a future POSIX - // pwsh-sandbox composition must gate the CLM sentence on the platform - // instead (tracked in the pwsh-tool-and-executor Agent Note). + // The CLM and named-pipe contracts below are Windows-restricted-token + // behavior, but the gate is 'any confining executor is mounted' + // (escalationModes non-empty). The conflation is safe today because every + // shipped composition pairing tool-pwsh with a confining executor is + // win32-only; a future POSIX pwsh-sandbox composition must gate both + // sentences on the platform instead (tracked in the pwsh-tool-and-executor + // Agent Note). return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' + + 'In the same modes, programs cannot open named pipes, so a command that captures another ' + + 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default ' + + '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns ' + + 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: ' + + 'do not retry the command another way — escalate the exact command once or restructure it to ' + + 'avoid capturing output. ' + 'Attempting a command the sandbox may deny is safe and expected: run it and read the ' + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it ' + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry ' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 1464ca14d3..91ad796ce5 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -552,13 +552,15 @@ describe('sandbox escalation through ctx.approval', () => { justification: 'the command needs workspace writes', } - it('advertises the sandbox fields, the escalation clause, and the ConstrainedLanguage contract', async () => { + it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => { const { ctx } = await setupSandboxed() const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! const properties = schema.parameters.properties as Record expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) expect(schema.description).toContain('approval prompt') expect(schema.description).toContain('ConstrainedLanguage') + expect(schema.description).toContain('named pipes') + expect(schema.description).toContain('fails with EPERM') for (const args of [ { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' }, @@ -569,10 +571,11 @@ describe('sandbox escalation through ctx.approval', () => { } }) - it('the escalation fields and the ConstrainedLanguage clause stay out of sandbox-less compositions', async () => { + it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => { const { ctx } = await setup() const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')! expect(schema.description).not.toContain('ConstrainedLanguage') + expect(schema.description).not.toContain('named pipes') expect(schema.description).not.toContain('sandbox_permissions') expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions') }) From d7e19a0977cb5b10dc64b05cd2f17027b347a914 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 17:06:36 +0800 Subject: [PATCH 53/81] docs(sandbox): pin the translated confinement-runner heading anchor The upgraded md-links gate resolves same-file fragments against the zh file's own heading slugs; the translated heading needs the explicit a-id anchor the corpus convention uses for cross-language fragments. --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 57ce89dba1..30b9f04979 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md README.md: 6f649f5dcc4ecf5c2cbbe90a687af25e0c6ecc9d -README.zh.md: e688218b2c12a529981433777a1163e3648c918a +README.zh.md: 11c0fdd4b7431a280ff656ae4d2bd63b81e9dd06 diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index e688218b2c..11c0fdd4b7 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -29,6 +29,8 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work 直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 + + ## 隔离 runner 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: From 428f84f710787a821f49f80a1cf69e383b9a22e5 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 17:11:18 +0800 Subject: [PATCH 54/81] test(docs): split placed-image paths on either separator The placer tests receive absolute paths; on Windows the POSIX-only split kept the whole path as the basename. The windows-acl branch already carries this fix from an earlier merge-forward. --- scripts/project-doc-site.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index d3f00f3408..124656a4a1 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -148,7 +148,7 @@ describe('rewriteMarkdown', () => { repoRoot: root, repositoryRef: 'abc123', placeImage: (absPath) => { - const name = absPath.split('/').pop() ?? '' + const name = absPath.split(/[\\/]/u).pop() ?? '' placed.push(name) return `./${name}` }, @@ -167,7 +167,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`, + placeImage: absPath => `./${absPath.split(/[\\/]/u).pop() ?? ''}`, })).toBe('![logo](./logo.svg#view)\n') }) From 63ad5d6d9810961eef240ccba2ea093b190b557a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 18:53:38 +0800 Subject: [PATCH 55/81] refactor(cli): drop the redundant existence pre-check in resolveWindowsShellLayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadOverlayPatches already throws on a missing file (the caller named it, so absence is a misconfiguration) — the existsSync guard was a second fail-loud mechanism for the same miss with a prettier message. The loader's throw keeps the fail-loud contract the Windows-default note records. --- apps/cli/src/windows-shell.ts | 9 +++------ apps/cli/tests/windows-shell.spec.ts | 4 +++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index 425699ef4f..c36dc0519c 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -10,7 +10,6 @@ * @module @deepseek-ai/dsh/windows-shell */ -import { existsSync } from 'node:fs' import { join } from 'node:path' import type { PatchOptions } from '@cordisjs/plugin-include' import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' @@ -36,8 +35,9 @@ export interface WindowsShellLayer { * @param binName - the diagnostic prefix on thrown errors (`dsh`). * @returns the pwsh layer on win32, else `undefined`. A custom profile that * mounts no base bundle is skipped (it owns its shell stack); a base - * bundle that ships no Windows shell patch fails loud — the shipped - * package always carries it, so a miss is a broken installation. + * bundle whose Windows shell patch is missing fails loud in + * {@link loadOverlayPatches} — the shipped package always carries it, so + * a miss is a broken installation. */ export function resolveWindowsShellLayer( platform: NodeJS.Platform, @@ -48,8 +48,5 @@ export function resolveWindowsShellLayer( const base = layers.find(layer => layer.packageName === BASE_BUNDLE) if (base === undefined) return undefined const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) - if (!existsSync(label)) { - throw new Error(`${binName}: ${BASE_BUNDLE} ships no ${WINDOWS_SHELL_PATCH_FILENAME}`) - } return { label, patches: loadOverlayPatches(binName, label) } } diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 80ba40cc34..fc562b7008 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -60,8 +60,10 @@ describe('resolveWindowsShellLayer', () => { it('fails loud when the base bundle ships no Windows shell patch', () => { const base = tempBase() mkdirSync(base, { recursive: true }) + // The overlay loader owns the fail-loud contract: the caller named this + // file, so its absence is a misconfiguration, not "no overlay". expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) - .toThrow(/@deepseek-ai\/dsh-base ships no windows\.cordis\.patch\.yml/) + .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/) }) }) From 4cfe4366d7458513553bb1a4de346acdbbb81379 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 9 Aug 2026 22:58:10 +0800 Subject: [PATCH 56/81] docs(note): drop the stage-numbering residual from the windows-default note The roadmap stage reference dated the decision record; the purge standard removes change-history narration and stage numbering from implemented notes. --- .../feature/2026-08-01-windows-pwsh-default.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-01-windows-pwsh-default.md | 2 +- .../implemented/feature/2026-08-01-windows-pwsh-default.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index af9f64208f..49b713f534 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: ea45040a0eb6f1325ed8f3a02a477d5dfd614696 -2026-08-01-windows-pwsh-default.zh.md: 41324afccdd775cac1629ed69217cff4237be091 +2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 +2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index ea45040a0e..f0da86e52b 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -16,7 +16,7 @@ Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, on - **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. - **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. -The pwsh GUI rendering stage (stage 2 of the original roadmap) shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. +The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 41324afccd..41a6429eab 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -16,7 +16,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 - **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 - **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 -原路线图的阶段 2(pwsh GUI 渲染)已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 +pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 ## 备选方案 From 976020f2bb415d5475194b6f2577337212162b47 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 00:54:55 +0800 Subject: [PATCH 57/81] refactor(sandbox): derive the private temp dir; drop the acl-session record The durable sandbox/acl-session event carried a workspace binding that always equals the session cwd and a random temp path that only needed to be stable per session. Both are now derived: the temp subdirectory is sha256(session id + workspace), created exclusively and removed on provider dispose, so fork/resume semantics fall out of the derivation and the record, its fold/provision/tamper validation, the immediate flush kick, and the session-store dependency all disappear. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 25 -- docs/persistence-catalog.zh.md | 25 -- .../sandbox/sandbox-local/src/acl-session.ts | 126 ------- packages/sandbox/sandbox-local/src/index.ts | 172 +++++---- ...acl-session.spec.ts => acl-grants.spec.ts} | 356 ++++++++---------- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 8 +- .../sandbox/sandbox-windows-acl/README.zh.md | 8 +- 12 files changed, 257 insertions(+), 487 deletions(-) delete mode 100644 packages/sandbox/sandbox-local/src/acl-session.ts rename packages/sandbox/sandbox-local/tests/{acl-session.spec.ts => acl-grants.spec.ts} (52%) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index f2514c3cb7..c9d678da0e 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: d6915cadb5817679749474c1046d1c715adc2b9b -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 1533e39bf5c239730b6e027e5f0b345d4c6c42e4 +2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index d6915cadb5..7e8f229269 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` emp ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam still provisions one log-only `sandbox/acl-session` event per session (fork mints a fresh one; resume replays the same one) carrying the session's workspace binding and PRIVATE temp subdirectory — no SID, so the record's old SID-tamper surface does not exist; a fresh provision kicks an immediate persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root); the record is BOUND to its owning session id and validated at the fold (workspace/temp shape): a fork's copied parent record never provisions the child, and a tampered record fails loud instead of materializing grants. The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 random hex>` — unguessable, created exclusively, reparse points rejected — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the kernel's PUBLIC template (owner/SYSTEM/Admins full, Everyone read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose, self-healing across restarts via the durable per-session record — whose immediate flush precedes the ACEs, within the flush latency); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` (record fold/provision with the ownership binding — a fork's copied parent record never provisions the child — tamper validation on the workspace/temp paths, one-shot materialization with the immediate-flush kick, exclusive temp creation with reparse-point rejection, fork/resume temp reuse, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 1533e39bf5..eeb346b228 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 仍为每个会话供给一条 log-only 的 `sandbox/acl-session` 事件(fork 铸出新记录;恢复回放同一条),携带会话的工作区绑定与**私有**临时子目录——不含 SID,因此记录原先的 SID 篡改面已不存在;新供给在追加之后立即触发一次即时持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久);记录被**绑定**到其所属会话 id 并在 fold 处校验(工作区/临时路径形态):fork 复制的父记录绝不会为子会话供给记录,被篡改的记录会响亮失败而非物化授权。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 random hex>`——不可猜测、独占创建、拒绝 reparse point——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是内核的**公共模板**(owner/SYSTEM/Admins 全权、Everyone 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 回收,借助持久化的每会话记录跨重启自愈——其即时 flush 先于 ACE(flush 延迟内));授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-session.spec.ts` 钉住(带归属绑定的记录 fold/供给——fork 复制的父记录绝不会为子会话供给记录——工作区/临时路径的篡改校验、带即时 flush 触发的一次性物化、独占临时目录创建并拒绝 reparse point、fork/恢复临时目录复用、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 ## Related diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7a46a14aa5..e5dd4edde7 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: b07a02fcfca093c08acd206235e009d4d5fb9664 -persistence-catalog.zh.md: 6f75e4ae522b75fc48bf95df707a0bd0a990ea5c +persistence-catalog.md: a17cae015eaa107a900069de916dddb216b87ec7 +persistence-catalog.zh.md: 3aef073dedcff0b6addb99d7c287f4e5f372c402 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b07a02fcfc..a17cae015e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -488,31 +488,6 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ ### `sandbox/*` -#### `sandbox/acl-session` — log-only - -```ts persistence-catalog -/** - * The session's windows-acl write record was provisioned — log-only - * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): - * durable and replayable, never in the model transcript. The LAST such - * event owned by the session is its record ({@link sessionAclRecord}); - * the provider appends exactly one on the session's first Windows - * confined execution. The write SID itself is NOT stored — it is the - * per-workspace identity derived from `workspace` - * (`workspaceWriteSid`). - */ -'sandbox/acl-session': { - /** The owning session — the binding a fork's copied event cannot satisfy. */ - sessionId: SessionId - /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ - workspace: string - /** The session's private temp subdirectory under the host temp root. */ - tempDir: string -} -``` - -Source: [`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) - #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 6f75e4ae52..3aef073ded 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -490,31 +490,6 @@ export type SessionEvent = { ### `sandbox/*` -#### `sandbox/acl-session` — log-only - -```ts persistence-catalog -/** - * The session's windows-acl write record was provisioned — log-only - * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): - * durable and replayable, never in the model transcript. The LAST such - * event owned by the session is its record ({@link sessionAclRecord}); - * the provider appends exactly one on the session's first Windows - * confined execution. The write SID itself is NOT stored — it is the - * per-workspace identity derived from `workspace` - * (`workspaceWriteSid`). - */ -'sandbox/acl-session': { - /** The owning session — the binding a fork's copied event cannot satisfy. */ - sessionId: SessionId - /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ - workspace: string - /** The session's private temp subdirectory under the host temp root. */ - tempDir: string -} -``` - -来源:[`packages/sandbox/sandbox-local/src/acl-session.ts:43`](../packages/sandbox/sandbox-local/src/acl-session.ts) - #### `sandbox/mode` — log-only ```ts persistence-catalog diff --git a/packages/sandbox/sandbox-local/src/acl-session.ts b/packages/sandbox/sandbox-local/src/acl-session.ts deleted file mode 100644 index fb88eb10bb..0000000000 --- a/packages/sandbox/sandbox-local/src/acl-session.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * The windows-acl session write record — the DURABLE half of the seam's - * grant lifecycle. Each session owns exactly one record (its workspace - * binding plus one private temp subdirectory), stored as a log-only - * `sandbox/acl-session` event on the session log (the `sandbox/mode` - * precedent): replayable, never in the model transcript, and no external - * config store. The record carries NO SID: the write SID is the - * per-WORKSPACE identity derived from the workspace path - * (`workspaceWriteSid`) — deterministic across sessions and server - * restarts, so the workspace-root ACE materializes once per workspace per - * machine (the grant's exact-ACE skip makes every later provision O(1)) - * instead of once per session. The ACE half is server-lifetime state owned - * by the provider ({@link AclWriteGrant}: workspace ACEs standing, temp ACEs - * revocable); the record survives restarts so a resumed session reuses the - * SAME private temp subdirectory and the same derived SID — re-granting - * idempotently merges into (or skips) the standing ACEs. The record is - * BOUND to its owning session id, so a fork (which copies the parent's - * events, record included) never inherits the parent's temp identity — it - * provisions a fresh one. The record's payload is durable input and is - * validated at the fold (well-formed workspace/temp paths); a - * matching-but-tampered record fails loud. - * - * @module dsh-sandbox-local/acl-session - */ - -import { randomBytes } from 'node:crypto' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * The session's windows-acl write record was provisioned — log-only - * (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`): - * durable and replayable, never in the model transcript. The LAST such - * event owned by the session is its record ({@link sessionAclRecord}); - * the provider appends exactly one on the session's first Windows - * confined execution. The write SID itself is NOT stored — it is the - * per-workspace identity derived from `workspace` - * (`workspaceWriteSid`). - */ - 'sandbox/acl-session': { - /** The owning session — the binding a fork's copied event cannot satisfy. */ - sessionId: SessionId - /** The workspace root the grant applies to (the session's immutable cwd, as resolved). */ - workspace: string - /** The session's private temp subdirectory under the host temp root. */ - tempDir: string - } - } -} - -/** The durable per-session record carried by one `sandbox/acl-session` event. */ -export interface AclSessionRecord { - /** The owning session id (binds the record against fork inheritance). */ - sessionId: SessionId - /** The workspace root the record was provisioned for (the write SID derives from it). */ - workspace: string - /** The session's private temp subdirectory. */ - tempDir: string -} - -/** - * The session's record: the last `sandbox/acl-session` event owned by it, or - * undefined (never confined / a fork). Durable-input validation: tampered - * workspace or temp path fails loud. @param events/@param sessionId/@returns - * as below. - * @param events - session events (other types skipped). - * @param sessionId - owning session (fork binding). - * @returns the last owned record, or undefined without one. - */ -export function sessionAclRecord(events: readonly SessionEvent[], sessionId: SessionId): AclSessionRecord | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type !== 'sandbox/acl-session') continue - const data = event.data - // Fork copies the parent's record: skip non-owned records (fork mints fresh). - if (data.sessionId !== sessionId) continue - if (typeof data.workspace !== 'string' || data.workspace.length === 0) { - throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`) - } - if (typeof data.tempDir !== 'string' || dirname(data.tempDir) !== tmpdir()) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record carries a temp path outside the host temp root: ${JSON.stringify(data.tempDir)}`, - ) - } - return data - } - return undefined -} - -/** - * The session's private temp subdirectory name: `\dsh-<16 random hex>`. - * The name is RANDOM and persisted in the record — convergence across server - * restarts comes from the record (the same SID re-grants the same directory), - * not from any derivation an attacker (who knows the session id through - * `DSH_SESSION_ID`) could predict and pre-place. The provider creates it - * exclusively and rejects reparse points; OS temp hygiene may reclaim it — - * deliberately no GC here. - * @returns the private temp subdirectory path. - */ -export function sessionTempDir(): string { - return join(tmpdir(), `dsh-${randomBytes(8).toString('hex')}`) -} - -/** - * Provision the record for a session that has none (its first Windows - * confined execution): the workspace binding plus the private temp - * subdirectory, appended as exactly one log-only `sandbox/acl-session` - * event — the provision IS its event, nothing mutates record state out of - * band. Fork (whose copied parent record is not its own) provisions a fresh - * record; resume replays the stored one. - * @param session - the session the record belongs to. - * @param workspaceRoot - the resolved policy root (the session's immutable cwd). - * @returns the provisioned record. - */ -export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord { - const record: AclSessionRecord = { - sessionId: session.id, - workspace: workspaceRoot, - tempDir: sessionTempDir(), - } - session.append('sandbox/acl-session', record) - return record -} diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 5ddf280063..e5d82c82d1 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -7,8 +7,8 @@ * * The windows-acl rung additionally owns the write grants: the write SID is * the per-WORKSPACE identity derived from the canonical workspace path - * (`workspaceWriteSid`), and one private temp subdirectory per session - * (durable record in the session log — see `./acl-session.ts`). The + * (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per + * session (session id + workspace — nothing stored). The * workspace-root ACE materializes once per workspace per server lifetime * and STANDS (the cross-session reuse cache — the exact-ACE skip makes * every later provision O(1) instead of re-propagating the tree per @@ -19,8 +19,10 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { LAUNCHER_BIN, @@ -35,8 +37,6 @@ import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandb import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' -import { provisionAclSession, sessionAclRecord } from './acl-session.ts' -import type { AclSessionRecord } from './acl-session.ts' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -110,6 +110,25 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): return probe.status === 0 } +/** + * The session's private temp subdirectory: `\dsh-<16 hex>`, derived + * from the session id and its workspace instead of stored. The same session + * and workspace always name the same directory — a resumed session + * re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's + * different session id names a fresh one. The name is predictable to anyone + * who knows the session id (the confined command sees it as + * `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and + * rejects reparse points: a pre-placed entry fails the first confined run + * loudly, and cannot redirect the grant onto a foreign object. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. + * @returns the session's private temp subdirectory path. + */ +export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string { + const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex') + return join(tmpdir(), `dsh-${digest.slice(0, 16)}`) +} + /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -132,6 +151,8 @@ export interface SandboxInternals { windowsAclRunnerEntry?: string /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */ probeWindowsAcl?: () => boolean + /** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */ + rmTempDir?: (path: string) => void } /** The chain's verdict: which runner confines, and how completely it enforces. */ @@ -257,12 +278,12 @@ export class LocalSandboxProvider extends SandboxProvider { * Server-lifetime write grants (windows-acl rung): the STANDING * workspace-root grant per workspace (its ACE is the cross-session reuse * cache and outlives the provider — never revoked) and the REVOCABLE - * private-temp grant per session (revoked on provider dispose); the - * durable half (workspace binding + private temp dir) lives in the - * session log (`./acl-session.ts`). + * private-temp grant per session (revoked on provider dispose). */ private readonly workspaceGrants = new Map() private readonly tempGrants = new Map() + /** Session id → the private temp directory this provider created (removed on dispose). */ + private readonly tempDirs = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -335,18 +356,15 @@ export class LocalSandboxProvider extends SandboxProvider { } /** - * The windows-acl runner argv for one policy. With a calling session - * (the policy's `sessionId`), the session's durable record is folded from - * the session log (provisioned on first use), its ACEs materialized once - * per server lifetime, and the runner receives `--write-sid` plus the - * session's PRIVATE temp subdirectory — it grants nothing and revokes - * nothing. A fresh provision kicks an IMMEDIATE persistence flush right - * after the append (no write-behind debounce delay), narrowing the - * crash-and-lose-record window to the flush latency itself — the residual - * is documented in the README (the spawn seams are synchronous, so no - * await barrier exists between record and ACEs). Agentless calls (no - * session) pass no SID: the runner self-manages per-call grants on the - * ambient temp root. + * The windows-acl runner argv for one policy. With a calling session (the + * policy's `sessionId`), the write grants are materialized once per server + * lifetime — the standing workspace-root grant per workspace and the + * revocable private-temp grant per session — and the runner receives + * `--write-sid` (the workspace-derived identity; its presence marks the + * seam-managed DACL contract) plus, under workspace-write, the session's + * PRIVATE temp subdirectory (derived from session id + workspace) — it + * grants nothing and revokes nothing. Agentless calls pass the ambient + * temp root and no `--write-sid`: the runner self-manages its DACLs. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ @@ -360,8 +378,7 @@ export class LocalSandboxProvider extends SandboxProvider { '--mode', policy.mode, ] } - const record = this.aclSessionRecord(sessionId, policy.workspaceRoot) - this.materializeAclGrant(record, policy.mode) + this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, @@ -370,79 +387,40 @@ export class LocalSandboxProvider extends SandboxProvider { // runs pass the ambient temp root — the runner validates it exists // but grants nothing. The derived write SID is the per-workspace // identity; the flag's presence marks the seam-managed DACL contract. - '--temp', policy.mode === 'workspace-write' ? record.tempDir : tmpdir(), + '--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(), '--mode', policy.mode, - '--write-sid', workspaceWriteSid(record.workspace), + '--write-sid', workspaceWriteSid(policy.workspaceRoot), ] } /** - * Fold (or provision) the calling session's durable windows-acl record. - * The provision appends exactly one log-only `sandbox/acl-session` event - * to the session log and kicks an immediate persistence flush (the - * write-behind coordinator's bounded window would otherwise delay the - * record's durability past its ACE materialization); the record's - * workspace must equal the policy root — both derive from the session's - * immutable cwd, so a mismatch is a corrupted composition and fails loud. - * @param sessionId - the policy's calling-session identity. - * @param workspaceRoot - the resolved policy root. - * @returns the session's record. - */ - private aclSessionRecord(sessionId: SessionId, workspaceRoot: string): AclSessionRecord { - const store = this.ctx.get('sessions') - if (store === undefined) { - throw new Error('sandbox-local: per-session windows-acl confinement requires the session store (ctx.sessions)') - } - const session = store.get(sessionId) - if (session === undefined) { - throw new Error(`sandbox-local: windows-acl policy carries session "${sessionId}" but ctx.sessions has no such session`) - } - const existing = sessionAclRecord(session.events, sessionId) - if (existing !== undefined) { - if (existing.workspace !== workspaceRoot) { - throw new Error( - `sandbox-local: session "${sessionId}" acl record workspace ${JSON.stringify(existing.workspace)} ` - + `does not match the resolved policy root ${JSON.stringify(workspaceRoot)} (session cwd is immutable)`, - ) - } - return existing - } - const record = provisionAclSession(session, workspaceRoot) - // Immediate durability kick: the append is write-behind (bounded - // coordinator window); flush now so the record is durable as close to - // its ACE materialization as the synchronous confine seam allows. The - // residual window (a crash inside the flush latency) strands the - // private temp directory unrecorded — documented in the README. - void store.flush(session) - return record - } - - /** - * Materialize the record's ACEs once per server lifetime: lazily at the - * session's first confined execution, reused for every later call (the map - * hits are the whole call). The write SID is the per-workspace identity - * derived from the record's workspace. Workspace-write grants the - * workspace root STANDING (the ACE outlives every session — the reuse - * cache) and the private temp subdirectory REVOCABLY — created here - * EXCLUSIVELY (the name is random and unguessable, a pre-existing entry - * throws EEXIST, and a reparse point is rejected, so the grant never - * lands on an attacker-placed object); read-only materializes NOTHING — - * its token alone restricts every write, and the standing grant from an + * Materialize the session's ACEs once per server lifetime: lazily at its + * first confined execution, reused for every later call (the map hits are + * the whole call). The write SID is the per-workspace identity derived + * from the workspace. Workspace-write grants the workspace root STANDING + * (the ACE outlives every session — the reuse cache) and the session's + * private temp subdirectory REVOCABLY — the directory is derived from + * session id + workspace, created here EXCLUSIVELY (a pre-existing entry + * or a reparse point fails the first confined run loudly, so the grant + * never lands on a foreign object); read-only materializes NOTHING — its + * token alone restricts every write, and the standing grant from an * earlier workspace-write period is KEPT through a downgrade (never * revoked): the read-only restricted token carries no write SID (the * read-only list), so the ACE is inert there, while the map hit keeps the * re-upgrade free of re-propagation. Fail-closed: a half-materialized * temp grant is revoked before the error propagates. - * @param record - the session's durable record. + * @param sessionId - the policy's calling-session identity. + * @param workspaceRoot - the resolved policy root. * @param mode - the policy mode (grants exist only under workspace-write). */ - private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void { + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void { if (mode === 'read-only') return - const writeSid = workspaceWriteSid(record.workspace) - if (!this.workspaceGrants.has(record.workspace)) { + const writeSid = workspaceWriteSid(workspaceRoot) + const tempDir = sessionTempDir(sessionId, workspaceRoot) + if (!this.workspaceGrants.has(workspaceRoot)) { const grant = AclWriteGrant.create(writeSid) try { - grant.add(record.workspace, true) + grant.add(workspaceRoot, true) } catch (error) { // Free the SID; a standing ACE (if the apply succeeded before a // post-apply throw) is the intended end state, not an error @@ -454,17 +432,23 @@ export class LocalSandboxProvider extends SandboxProvider { } throw error } - this.workspaceGrants.set(record.workspace, grant) + this.workspaceGrants.set(workspaceRoot, grant) } - if (this.tempGrants.has(record.sessionId)) return + if (this.tempGrants.has(sessionId)) return const grant = AclWriteGrant.create(writeSid) + // The directory is removed again in the catch only when THIS confine + // created it — a pre-existing entry (EEXIST) is a foreign object and is + // never deleted. + let created = false try { // Exclusive creation (no `recursive`): a pre-existing entry OR a // reparse point both fail EEXIST — the grant never lands on a foreign // object. - mkdirSync(record.tempDir) - grant.add(record.tempDir) + mkdirSync(tempDir) + created = true + grant.add(tempDir) } catch (error) { + if (created) rmSync(tempDir, { recursive: true, force: true }) // Revoke whatever stands and free the SID — never leave a half-grant // behind a failed confine (the runner never runs). try { @@ -474,15 +458,18 @@ export class LocalSandboxProvider extends SandboxProvider { } throw error } - this.tempGrants.set(record.sessionId, grant) + this.tempGrants.set(sessionId, grant) + this.tempDirs.set(sessionId, tempDir) } /** * Dispose every write grant (provider dispose): the revocable temp ACEs - * are revoked and every SID allocation freed; the standing workspace ACEs + * are revoked, the private temp directories this provider created are + * removed, and every SID allocation is freed; the standing workspace ACEs * stay (the reuse cache). Cleanup failures are reported, not thrown: - * cordis teardown must not be aborted by grant cleanup, and the durable - * records make a missed revocation self-healing on the next resume. + * cordis teardown must not be aborted by grant cleanup. A crash skips all + * of it — the next resume then fails loudly at the exclusive creation and + * OS temp hygiene (or manual removal) recovers. */ private revokeAclGrants(): void { if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return @@ -494,8 +481,17 @@ export class LocalSandboxProvider extends SandboxProvider { failures.push(error) } } + const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => rmSync(dir, { recursive: true, force: true })) + for (const dir of this.tempDirs.values()) { + try { + rmTempDir(dir) + } catch (error) { + failures.push(error) + } + } this.workspaceGrants.clear() this.tempGrants.clear() + this.tempDirs.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) diff --git a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts similarity index 52% rename from packages/sandbox/sandbox-local/tests/acl-session.spec.ts rename to packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index c6ee33f3e8..ca2410c317 100644 --- a/packages/sandbox/sandbox-local/tests/acl-session.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,11 +1,10 @@ /** - * windows-acl write grants: the DURABLE record (log-event fold/provision with - * ownership binding + tamper validation) plus the SERVER-LIFETIME ACE - * materialization (standing workspace grant per workspace, revocable temp - * grant per session), through the REAL LocalSandboxProvider.confine() with a - * real session store. Win32 surface mocked at the package boundary (the - * workspace-derived SID mocked to a constant); the real-FFI grant behavior - * lives in sandbox-windows-acl's win32 tests. + * windows-acl write grants: the SERVER-LIFETIME ACE materialization + * (standing workspace grant per workspace, revocable private-temp grant per + * session) plus the derived private-temp identity, through the REAL + * LocalSandboxProvider.confine(). Win32 surface mocked at the package + * boundary (the workspace-derived SID mocked to a constant); the real-FFI + * grant behavior lives in sandbox-windows-acl's win32 tests. */ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' @@ -14,15 +13,15 @@ import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' -import { sessionTempDir } from '../src/acl-session.ts' +import { SessionId } from '@deepseek-ai/dsh-session' +import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, + /** Restricts {@link addFailure} to this path (undefined = every add throws). */ + addFailurePath: undefined as string | undefined, disposeFailure: undefined as Error | undefined, })) @@ -39,7 +38,9 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { - if (mockState.addFailure !== undefined) throw mockState.addFailure + if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) { + throw mockState.addFailure + } this.added.push({ path, standing }) } dispose(): void { @@ -53,28 +54,17 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { /** The workspace-derived write SID the mock pins for every workspace. */ const DERIVED_SID = 'S-1-4-42-42' -/** One provisioned record event, shaped like the live log's envelope. */ -function recordEvent(record: { sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent { - return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record } -} - async function setup() { const ctx = new Context() - await ctx.plugin(SessionStore) const fiber = await ctx.plugin(LocalSandboxProvider, {}) const sandbox = ctx.sandbox as LocalSandboxProvider sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } return { ctx, sandbox, fiber } } -/** A workspace root the policy, the record, and the session cwd all share. */ +/** A workspace root the policy carries. */ function workspaceRoot(): string { - return mkdtempSync(join(tmpdir(), 'dsh-acl-session-ws-')) -} - -/** A well-shaped private temp path under the host temp root (never created). */ -function shapedTempPath(): string { - return join(tmpdir(), `dsh-${'ab'.repeat(8)}`) + return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')) } describe('windows-acl write grants (LocalSandboxProvider)', () => { @@ -83,6 +73,7 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined + mockState.addFailurePath = undefined mockState.disposeFailure = undefined }) @@ -90,21 +81,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine provisions the record and materializes ONCE (standing workspace + revocable private temp)', async () => { + it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => { try { - const { ctx, sandbox, fiber } = await setup() + const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-1'), { meta: { cwd: ws } }) + const tempDir = sessionTempDir(SessionId('sess-1'), ws) + scratch.push(tempDir) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) - expect(confined.argv).toContain('--write-sid') - expect(confined.argv).toContain(DERIVED_SID) - expect(confined.argv).toContain('workspace-write') + expect(confined.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tempDir, + '--mode', 'workspace-write', + '--write-sid', DERIVED_SID, + '--', + 'pwsh', '/Command', 'x', + ]) expect(mockState.grants).toHaveLength(2) - const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir - scratch.push(tempDir) expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked @@ -116,12 +112,10 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { disposed: false, }) expect(existsSync(tempDir)).toBe(true) // created exclusively - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) // Reuse: the second confine is the map hits. sandbox.confine(['pwsh', '/Command', 'x'], policy) expect(mockState.grants).toHaveLength(2) - expect(session.events).toHaveLength(1) await fiber.dispose() // dispose() runs on BOTH grants: the standing workspace ACE is left in @@ -135,66 +129,17 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-switch'), { meta: { cwd: ws } }) + const tempDir = sessionTempDir(SessionId('sess-switch'), ws) + scratch.push(tempDir) const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } - // read-only first: record rides along, nothing materialized, ambient temp. + // read-only first: nothing materialized, ambient temp. const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toContain('--write-sid') - expect(confinedRo.argv).toContain(tmpdir()) - expect(mockState.grants).toHaveLength(0) - const record = session.events.filter(event => event.type === 'sandbox/acl-session')[0]!.data as { tempDir: string } - scratch.push(record.tempDir) - expect(existsSync(record.tempDir)).toBe(false) - - // Upgrade: first workspace-write materializes with the derived SID. - const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', record.tempDir, - '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, - '--', - 'true', - ]) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: record.tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(record.tempDir)).toBe(true) - - // Reuse: map hits. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(2) - - // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). - sandbox.confine(['true'], readOnly) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - } finally { - cleanup() - } - }) - - it('read-only: the record rides along (--write-sid, one event) but NOTHING is materialized and the ambient temp root is passed', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-ro'), { meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-ro') } - - const confined = sandbox.confine(['true'], policy) - expect(confined.argv).toEqual([ + expect(confinedRo.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing @@ -204,90 +149,89 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) + expect(existsSync(tempDir)).toBe(false) + + // Upgrade: first workspace-write materializes with the derived SID. + const upgraded = sandbox.confine(['true'], workspaceWrite) + expect(upgraded.argv).toEqual([ + 'node', 'windows-acl-runner.js', + '--workspace', ws, + '--temp', tempDir, + '--mode', 'workspace-write', + '--write-sid', DERIVED_SID, + '--', + 'true', + ]) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: tempDir, standing: false }], + disposed: false, + }) + expect(existsSync(tempDir)).toBe(true) + + // Reuse: map hits. + sandbox.confine(['true'], workspaceWrite) + expect(mockState.grants).toHaveLength(2) + + // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). + sandbox.confine(['true'], readOnly) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) } finally { cleanup() } }) - it('resume: a seeded record replays the same derived SID and temp dir with no second event appended', async () => { + it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => { try { const ws = workspaceRoot() scratch.push(ws) - const tempDir = shapedTempPath() - const record = { sessionId: SessionId('resumed'), workspace: ws, tempDir } - scratch.push(tempDir) - const first = await setup() - const session = first.ctx.sessions.create(SessionId('resumed'), { seed: [recordEvent(record)], meta: { cwd: ws } }) - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } - const confined = first.sandbox.confine(['true'], policy) - expect(confined.argv).toContain(DERIVED_SID) // re-derived from the record's workspace + const firstConfined = first.sandbox.confine(['true'], policy) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: tempDir, standing: false }] }) - // Replay IS the state: nothing appended. - expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1) - expect(session.events).toHaveLength(2) + + // Clean restart: dispose revokes the temp ACE and removes the private + // temp directory, so the fresh provider's exclusive creation succeeds. + await first.fiber.dispose() + mockState.grants = [] + const second = await setup() + const secondConfined = second.sandbox.confine(['true'], policy) + expect(secondConfined.argv).toEqual(firstConfined.argv) + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[1]).toMatchObject({ + writeSid: DERIVED_SID, + added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }], + }) + await second.fiber.dispose() } finally { cleanup() } }) - it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh temp identity (sessionId binding)', async () => { + it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const parentTemp = shapedTempPath() - const parentRecord = { sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp } + const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') } + const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + + sandbox.confine(['true'], parentPolicy) + const parentTemp = sessionTempDir(SessionId('parent'), ws) scratch.push(parentTemp) - // SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record. - const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } }) + sandbox.confine(['true'], childPolicy) + const childTemp = sessionTempDir(SessionId('child'), ws) + scratch.push(childTemp) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) // Fresh temp identity, NOT the parent's (the workspace SID is shared by - // derivation — the workspace is the same). - const childTemp = (child.events.at(-1)!.data as { tempDir: string }).tempDir + // derivation — the workspace is the same, so the standing grant is the + // map hit and only the child's temp grant joins). expect(childTemp).not.toBe(parentTemp) - expect(mockState.grants[1]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) - expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh - } finally { - cleanup() - } - }) - - it('fails loud on a matching-but-tampered record: foreign temp path, empty workspace, and non-string fields never materialize', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - - // tempDir outside the host temp root. - const foreignTemp = { sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' } - ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } }) - const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') } - expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/) - - // Non-string durable fields (a corrupted/tampered JSONL payload): the - // typeof guards fail loud before any string operation runs. There is - // NO stored SID to tamper with — the write SID is derived from the - // workspace path, so the old "SID rewritten to Everyone" attack - // surface does not exist. - const cases: Array<{ id: string; record: Record; expect: RegExp }> = [ - { id: 'tampered-type-ws-null', record: { sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-ws-empty', record: { sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ }, - { id: 'tampered-type-temp', record: { sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ }, - ] - for (const c of cases) { - ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId(c.id) } - expect(() => sandbox.confine(['true'], policy), c.id).toThrow(c.expect) - } - expect(mockState.grants).toHaveLength(0) + expect(mockState.grants).toHaveLength(3) + expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) } finally { cleanup() } @@ -295,16 +239,14 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. - const preexisting = shapedTempPath() + const preexisting = sessionTempDir(SessionId('preexisting'), ws) mkdirSync(preexisting) scratch.push(preexisting) - const preRecord = { sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting } - ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } }) const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) // The standing workspace grant is the intended end state and stays; the @@ -316,11 +258,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { // Reparse point: same EEXIST (exclusive mkdir never follows links). const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) scratch.push(target) - const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name + const linkPath = sessionTempDir(SessionId('reparse'), ws) symlinkSync(target, linkPath) scratch.push(linkPath) - const linkRecord = { sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath } - ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } }) const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) // Same workspace as the preexisting case: the standing workspace grant @@ -333,11 +273,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { // fails — the temp cleanup AggregateError propagates. mockState.grants = [] mockState.disposeFailure = new Error('temp cleanup exploded') - const dupTemp = shapedTempPath().replace(/abab$/, 'efef') + const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws) mkdirSync(dupTemp) scratch.push(dupTemp) - const dupRecord = { sessionId: SessionId('temp-cleanup-fail'), workspace: ws, tempDir: dupTemp } - ctx.sessions.create(SessionId('temp-cleanup-fail'), { seed: [recordEvent(dupRecord)], meta: { cwd: ws } }) const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) @@ -346,49 +284,17 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('fails loud when the durable record\'s workspace does not match the resolved policy root', async () => { - try { - const { ctx, sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const mismatched = { sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() } - ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } }) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') } - expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/) - expect(mockState.grants).toHaveLength(0) - } finally { - cleanup() - } - }) - - it('fails loud without the session store, and when the policy names a session the store does not hold', async () => { - try { - const bare = new Context() - await bare.plugin(LocalSandboxProvider, {}) - const sandbox = bare.sandbox as LocalSandboxProvider - sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] } - const policy: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws', sessionId: SessionId('sess-none') } - expect(() => sandbox.confine(['true'], policy)).toThrow(/requires the session store/) - - const { sandbox: withStore } = await setup() - expect(() => withStore.confine(['true'], policy)).toThrow(/no such session/) - } finally { - cleanup() - } - }) - it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { try { - const { ctx, sandbox } = await setup() + const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } }) + scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws)) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. mockState.addFailure = new Error('grant exploded') expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') - scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) @@ -402,7 +308,29 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no session store involved', async () => { + it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => { + try { + const { sandbox } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') } + + // The workspace grant succeeds; only the TEMP grant's add throws (the + // path-targeted failure keeps the workspace branch intact). + mockState.addFailurePath = tempDir + mockState.addFailure = new Error('temp add exploded') + expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded') + expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again + expect(mockState.grants).toHaveLength(2) + expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays + expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes + } finally { + cleanup() + } + }) + + it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => { try { const { sandbox, fiber } = await setup() const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } @@ -427,10 +355,9 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const session = ctx.sessions.create(SessionId('sess-dispose'), { meta: { cwd: ws } }) + scratch.push(sessionTempDir(SessionId('sess-dispose'), ws)) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } sandbox.confine(['true'], policy) - scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir) expect(mockState.grants).toHaveLength(2) mockState.disposeFailure = new Error('revoke exploded') @@ -444,11 +371,34 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('sessionTempDir names are random and well-shaped (unpredictable, never derivable from the session id)', () => { - const a = basename(sessionTempDir()) - const b = basename(sessionTempDir()) - expect(a).not.toBe(b) - expect(a).toMatch(/^dsh-[0-9a-f]{16}$/) - expect(b).toMatch(/^dsh-[0-9a-f]{16}$/) + it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + try { + const { ctx, sandbox, fiber } = await setup() + const ws = workspaceRoot() + scratch.push(ws) + scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws)) + const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') } + sandbox.confine(['true'], policy) + expect(mockState.grants).toHaveLength(2) + + sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') } + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() + // Both grants dispose cleanly; only the directory removal fails. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' })) + } finally { + cleanup() + } + }) + + it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => { + const base = sessionTempDir(SessionId('sess-a'), '/ws/a') + expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/) + expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base) + expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session + expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace + // The separator prevents id/workspace collisions from merging inputs. + expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc')) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 30b9f04979..c53e5cf6fc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 6f649f5dcc4ecf5c2cbbe90a687af25e0c6ecc9d -README.zh.md: 11c0fdd4b7431a280ff656ae4d2bd63b81e9dd06 +README.md: b13160f7490878143c719ca617936b74ffd298af +README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 6f649f5dcc..b13160f749 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -39,7 +39,7 @@ node runner.js --workspace --temp --mode The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam still provisions ONE log-only `sandbox/acl-session` event per session (bound to the owning session id, validated at the fold) carrying the session's workspace binding and PRIVATE temp subdirectory: a resumed session replays the same temp dir, a fork mints a fresh one. The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. A fresh provision kicks an IMMEDIATE persistence flush right after the append (no write-behind debounce), so the record is durable within the flush latency — a crash inside that window can strand the private temp directory unrecorded, the one documented self-healing gap (the spawn seams are synchronous, so no await barrier exists between record and ACEs). Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). +**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): - `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. @@ -65,8 +65,8 @@ The koffi struct definitions assert their sizes against the probe at module load - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 random hex>`, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. -- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory itself is plain `%TEMP%` litter with no garbage collection: OS temp hygiene reclaims it, and the record's determinism lets a later resume reuse it. +- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. +- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience @@ -85,7 +85,7 @@ None directly; the denial surface belongs to the tool layer. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. - **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. -- **Resuming one session concurrently in two server processes races the record.** The durable record lives in the session log; both processes read or provision it independently — the derived write SID is identical, the per-path lock keeps the DACL merges consistent, and the private temp dir race resolves by the last-written record winning for future resumes. Single-writer session usage (the normal deployment) never sees this. +- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. - **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 11c0fdd4b7..9895449f6f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -41,7 +41,7 @@ node runner.js --workspace --temp --mode runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID(先前每会话随机 SID 及其篡改面已移除)。seam 仍会为每个会话只供给一条仅作日志记录的 `sandbox/acl-session` 事件(绑定其所属会话 id,在 fold 处校验),携带会话的工作区绑定与**私有**临时子目录:恢复的会话回放同一个临时目录,fork 则铸造一个新的。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。新供给在追加之后立即触发一次**即时**持久化 flush(无 write-behind 去抖),因此记录在 flush 延迟内即持久化——在该窗口内崩溃可能遗留未记录的私有临时目录,这是唯一记录在案的自愈缺口(spawn seam 是同步的,因此记录与 ACE 之间不存在 await 屏障)。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 +**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): - `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 @@ -67,8 +67,8 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 -- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 位随机 hex>`,独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 -- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录本身只是 `%TEMP%` 下的普通垃圾、没有垃圾回收:OS 对临时目录的日常清理会回收它,记录的确定性让之后的恢复可以复用它。 +- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 +- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 ## Model Experience @@ -87,7 +87,7 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 - **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 -- **两个服务器进程并发恢复同一会话会竞争记录。** 持久记录在会话日志中;两个进程独立读取或供给它——派生出的写入 SID 相同,每路径锁保持 DACL 合并一致,私有临时目录的竞争以后写记录对后续恢复生效而解决。单写者会话用法(常规部署)永远不会遇到。 +- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 - **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 From b5934b6f7ce7793c149c2d35f06d31a47e2d2e66 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:04:44 +0800 Subject: [PATCH 58/81] cleanup(web): remove the steering interjection caption Steering bubbles render as plain user bubbles; a mid-turn steer is recognizable by its position in the flow. The runtime SteeringMessageNode projection and pending-steering lifecycle are unchanged. Partially supersedes the 2026-08-04 context-source and steer marks note; the new simplification note owns the removal rationale. --- ...b-context-source-and-steer-marks.i18n.yaml | 4 +-- ...8-04-web-context-source-and-steer-marks.md | 5 +-- ...4-web-context-source-and-steer-marks.zh.md | 5 +-- ...ve-steering-interjection-caption.i18n.yaml | 6 ++++ ...eb-remove-steering-interjection-caption.md | 36 +++++++++++++++++++ ...remove-steering-interjection-caption.zh.md | 36 +++++++++++++++++++ .../plan-review/approved.expected.md | 2 +- .../snapshots/steering/mid-steer.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.module.css | 9 ----- .../src/client/chat/MessageItem.tsx | 12 ++----- .../ui-conversation/src/client/locales.ts | 2 -- .../tests/chat-branch-tails.spec.tsx | 3 +- .../ui-conversation/tests/chat-view.spec.tsx | 4 --- 17 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md create mode 100644 .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index cdcbcd8d4e..6bc552736e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: d74badedaa9623014dbba10ae68ca680daf75de1 -2026-08-04-web-context-source-and-steer-marks.zh.md: b2fd990f6f868fdf422ba97ddd25d19705041e3a +2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb +2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index d74badedaa..0285f3ef1d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -37,11 +37,12 @@ The transcript names all three roles a non-prompt message can play — injected ## Testing - `packages/client/runtime` unit coverage pins each source kind, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths. -- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. -- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, and the roleless header. +- The keyless assembled-Web goldens carry the named header, so the assembled transcript — not only component tests — proves the marks. ## Consequences +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index b2fd990f6f..872ab30d72 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -37,11 +37,12 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 测试 - `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。 -- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 -- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存,以及无名时的标题形态。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 ## 后果 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml new file mode 100644 index 0000000000..7194db5780 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md +2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md new file mode 100644 index 0000000000..2c396f5494 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +English | [中文](2026-08-10-web-remove-steering-interjection-caption.zh.md) + +## Problem + +The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) captioned every durable and pending steering bubble with `插话` / `Interjection` so the transcript could say which right-aligned bubble interrupted a running turn. The caption repeats what the flow already shows: a steering bubble sits mid-turn, between the assistant content it interrupted, while a turn-opening prompt sits at a turn boundary. A permanent line of tertiary text above every steer bubble buys no reading a position-aware reader does not already have, and it is the only chrome any user-style bubble carries, so it also breaks the otherwise uniform right-aligned rhythm. + +## Decision + +Steering renders exactly as a user bubble. `UserStyleBubble` has no steering flag, the `message.steering` locale key and the `.steeringMark` style are deleted, and `PendingSteeringBubble` and `UserMessageNodeView` pass only content and actions. A mid-turn steer is recognizable by its position inside the running turn's flow, and by nothing else. + +The runtime distinction is untouched. `SteeringMessageNode` projection from durable `agent/inbox/spliced` history, the `data-pending-steering` attribute, and the pending-to-durable hand-off all remain: the pending lifecycle needs the node identity regardless of presentation, and tests still locate pending bubbles through the attribute. + +This partially supersedes the steering clause of the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md); its context-source and recall naming stays current. The caption has flipped before: the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) removed it while the composer could not steer, and the 2026-08-04 decision reintroduced it after the composer gained a Steer gesture. This removal does not revisit the gesture — steering entry, the Queue dock's steer-send action, and the pending lifecycle keep their owners — it judges only that the transcript need not name the result. + +## Alternatives considered + +**Keep the caption.** It is the status quo and cheap to keep, but it decorates every steer bubble forever to encode a fact the bubble's position already states. Chrome that carries no information a reader lacks is removed, not maintained. + +**Remove the `SteeringMessageNode` distinction too.** The node kind is derived from durable inbox history and drives the pending-to-durable hand-off; it is a replay fact, not presentation. Folding it into `UserMessageNode` would change projection behavior for no UI gain. + +**Distinguish steering with quieter chrome (tint, indent, hover-only label).** Any replacement re-raises the same question with a weaker vocabulary. The distinction the transcript needs is positional and already visible; adding subtler decoration keeps the cost and loses the one virtue the text caption had, being explicit. + +## Testing + +- `packages/client/ui-conversation` jsdom coverage pins the plain bubble: the pending hand-off test locates pending bubbles by `data-pending-steering` and asserts the single-bubble hand-off without any caption, and the MessageItem steering arm asserts copy-without-branch on an uncaptioned bubble. +- The keyless assembled-Web goldens (`steering/mid-steer`, `steering/settled`, `plan-review/approved`) replay the unchanged session fixtures with no caption text. + +## Consequences + +- A replayed transcript no longer names steering: a reader infers a mid-turn interjection from its position inside the turn. That inference is weaker than an explicit label for a reader skimming turn boundaries; the decision accepts this. +- A pending steer bubble is visually identical to an ordinary sent bubble until admission; only its missing clock time differs. +- Reintroducing steering chrome of any form requires a new product decision superseding this note. diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md new file mode 100644 index 0000000000..85d76e977a --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +[English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 + +## Problem + +[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 + +## Decision + +steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 + +运行时的区分保持不变。从持久 `agent/inbox/spliced` 历史投影 `SteeringMessageNode`、`data-pending-steering` 属性、待处理到持久的交接全部保留:待处理生命周期无论呈现如何都需要节点身份,测试也仍通过该属性定位待处理气泡。 + +本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 + +## Alternatives considered + +**保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 + +**连 `SteeringMessageNode` 区分一起删。** 节点类型派生自持久 inbox 历史,驱动待处理到持久的交接;它是回放事实,不是呈现。把它并入 `UserMessageNode` 会改变投影行为,却没有任何 UI 收益。 + +**换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 + +## Testing + +- `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 +- 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 + +## Consequences + +- 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 +- 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 +- 重新引入任何形式的 steering 装饰都需要一个取代本 note 的新产品决策。 diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c1cae54bb5..f6ef729c79 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 5f3f24f709..c60e8cff4f 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -21,7 +21,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d598613fa3..bfb72ade1f 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -19,7 +19,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6c197a5107..dc20ef5753 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: b57f88b5a030a6c20c957e26ea32fb125f106ab4 -README.zh.md: a8a8c4814086cad02c5416078f242ec92a7503d7 +README.md: 1def427a0d6c2d27e0adc7fbfa4a1185bc6b0b57 +README.zh.md: d603e8a337f934f8da9f136f0d42814bb1108acc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b57f88b5a0..1def427a0d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a8a8c48140..d603e8a337 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index c6ca35bb2c..6c86b39e4f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,15 +8,6 @@ gap: 6px; } -/* Steering caption above the bubble: mid-turn interjections carry the same - bubble as a turn-opening prompt, so the transcript names which one this is. */ -.steeringMark { - padding-right: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 16px; -} - .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ max-width: min(525px, 82%); diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0d85e85e8..49342537ef 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,7 +1,6 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy IconActions; steering adds the -// interjection caption that names it; branch lives only under assistant -// answers), pending steering (caption + copy only), context injection, +// (right-aligned, with clock + copy IconActions; branch lives only under +// assistant answers), pending steering (copy only), context injection, // compaction marker, retry disclosure, and unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' @@ -151,22 +150,19 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, steering = false, t, + content, actions, pending = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean - /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ - steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return (
- {steering && {t('message.steering')}}
{projectUserText(text)} {rest.map((block, i) => )} @@ -190,7 +186,6 @@ export function PendingSteeringBubble({ content, t }: { ( ( { expect(vi.getTimerCount()).toBe(0) }) - it('consumed steering is captioned as an interjection and keeps copy without branch', () => { + it('consumed steering renders as a plain user bubble and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -242,7 +242,6 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6b2072ddfb..4078edfe94 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -459,9 +459,6 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() - // Pending and durable steering carry the same interjection caption, so the - // hand-off does not change what the row says it is. - expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -483,7 +480,6 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() - expect(view.getAllByText('插话')).toHaveLength(1) // Only the durable steering bubble: the turn is still running, so its // assistant narration owns no footer yet, and a steering bubble never // carries a branch action. From 0306aefaa9e04318f13a216f659943dfa466bd61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:12:36 +0800 Subject: [PATCH 59/81] test(web): refresh steer-all mid golden against merged master UI --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 65f22f2140..20d799ca79 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -11,11 +11,6 @@ - img - img - text: Context injection @deepseek-ai/dsh-system-prompt -- text: Running -- button "Think": - - img - - img - - text: Think - status: Deep diving... - text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": From a3cf2617a8f7fef6846efc0e486478c4f1b70d97 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 11:26:37 +0800 Subject: [PATCH 60/81] =?UTF-8?q?docs(notes):=20fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20translate=20zh=20note=20headings,=20point=20superse?= =?UTF-8?q?ssion=20at=20the=20Decision,=20pin=20caption=20absence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...8-04-web-context-source-and-steer-marks.i18n.yaml | 4 ++-- .../2026-08-04-web-context-source-and-steer-marks.md | 2 +- ...26-08-04-web-context-source-and-steer-marks.zh.md | 2 +- ...eb-remove-steering-interjection-caption.i18n.yaml | 2 +- ...10-web-remove-steering-interjection-caption.zh.md | 12 ++++++------ .../ui-conversation/tests/chat-branch-tails.spec.tsx | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index 6bc552736e..86b48310e7 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 0285f3ef1d7cc9dda77322d6b6e61367ee0f12eb -2026-08-04-web-context-source-and-steer-marks.zh.md: 872ab30d7235bae55d4350a98654c5d16e34b968 +2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 +2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 0285f3ef1d..01bdca873a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -42,7 +42,7 @@ The transcript names all three roles a non-prompt message can play — injected ## Consequences -- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming below stays current, and the `SteeringMessageNode` projection is unchanged. +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming in the Decision stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index 872ab30d72..b6a9cc5692 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -42,7 +42,7 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 后果 -- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。下列上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。决策中的上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml index 7194db5780..2f63bbcfd7 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md 2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 -2026-08-10-web-remove-steering-interjection-caption.zh.md: 85d76e977a908393ba5e3a385b804acce3063660 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 088b36449130f9e8f1be5bec7a3ee4a812b4b6f1 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md index 85d76e977a..088b364491 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -1,14 +1,14 @@ -# Agent Note: Remove the steering interjection caption +# Agent Note: 移除 steering 插话标注 Status: implemented [English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 -## Problem +## 问题 [上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 -## Decision +## 决策 steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 @@ -16,7 +16,7 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 -## Alternatives considered +## 考虑过的替代方案 **保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 @@ -24,12 +24,12 @@ steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标 **换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 -## Testing +## 测试 - `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 - 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 -## Consequences +## 后果 - 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 - 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bd84f46ed6..5d1f027923 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -242,6 +242,7 @@ describe('MessageItem arms', () => { } as never} />, ) + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) From 85ebb03c0018bdd4a3c4d577818d9c4ac007f00f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 11:30:33 +0800 Subject: [PATCH 61/81] test(web): wait for the reasoning row before the steer-all mid snapshot The mid golden was racing the reasoning block's stream: captureStableAria could freeze on the pre-render gap between steering acceptance and the assistant step, pinning a snapshot without the Think row. Wait for the [data-variant=think] row so the golden captures the complete assistant step. --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 5 +++++ apps/web/tests/steering.e2e.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 20d799ca79..998ee98129 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -11,6 +11,11 @@ - img - img - text: Context injection @deepseek-ai/dsh-system-prompt +- text: Running +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": + - img + - img + - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... - text: "Interjection Interjection: include the word BANANA in your final reply." - button "Copy": diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 182f892d54..8a09582b56 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -353,6 +353,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { { timeout: 10_000 }, ).toBe(2) expect(await page.locator('[data-queue-dock]').count()).toBe(0) + // The reasoning row streams independently of the steering handoff; wait + // for it so the mid snapshot pins the assistant step, not the pre-render + // gap a fast machine can catch between steering acceptance and the block. + await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 }) const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE) From ad54135d4398406f81af394ca7217052453a7708 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 12:30:39 +0800 Subject: [PATCH 62/81] fix(sandbox-local): block-body the default rmTempDir fallback The arrow shorthand implicitly returns rmSync's void, which the no-confusing-void-expression rule forbids; the block body keeps the fallback without the violation. --- packages/sandbox/sandbox-local/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index e5d82c82d1..42a150b855 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -481,7 +481,7 @@ export class LocalSandboxProvider extends SandboxProvider { failures.push(error) } } - const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => rmSync(dir, { recursive: true, force: true })) + const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) }) for (const dir of this.tempDirs.values()) { try { rmTempDir(dir) From 0b9f4844c23697674596e6d7bef058ac3ccb99f6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 10 Aug 2026 12:30:46 +0800 Subject: [PATCH 63/81] fix(config): drop the obsolete lsp-local coverage exclusions Master's native Windows coverage lane now covers the LSP sources, and its ci-workflow spec asserts the exclusion paths are absent from vitest.config.ts; keeping them (as carried over in the merge) fails both the coverage and the native Windows lanes. The sandbox-windows-acl exclusion stays: that package is win32-only and the Linux lane cannot cover it. --- vitest.config.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 3907d527e5..68bfafec8d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,16 +37,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// These files retain 100% per-file coverage on POSIX, where their process-pipe and terminal timing -// tests are deterministic; Windows skips those cases and must not fail solely on their uncovered paths. -const windowsCoverageExclusions = process.platform === 'win32' - ? [ - 'packages/lsp/lsp-local/src/connection.ts', - 'packages/lsp/lsp-local/src/index.ts', - 'packages/lsp/lsp-local/src/instance.ts', - ] - : [] - // Windows-only packages: their sources execute exclusively on win32 (koffi // loads Win32 libraries), so the Linux coverage lane can never cover them. // The Windows dev/CI lane exercises them through the probe/runner suites; the @@ -238,7 +228,6 @@ export default defineConfig({ 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), - ...windowsCoverageExclusions, ...windowsOnlyCoverageExclusions, ...pwshCoverageExclusions, ], From a7fc81d745ad178194be6c905bb1a6b4c19cd624 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 22 Jul 2026 15:13:12 +0800 Subject: [PATCH 64/81] docs: replace front door terminology --- .agents/notes/README.i18n.yaml | 2 +- .agents/notes/README.md | 2 +- ...-15-llm-model-catalog-and-acp-selection.i18n.yaml | 4 ++-- ...2026-07-15-llm-model-catalog-and-acp-selection.md | 8 ++++---- ...6-07-15-llm-model-catalog-and-acp-selection.zh.md | 8 ++++---- ...026-07-19-gui-layering-and-rpc-protocol.i18n.yaml | 4 ++-- .../2026-07-19-gui-layering-and-rpc-protocol.md | 6 +++--- .../2026-07-19-gui-layering-and-rpc-protocol.zh.md | 6 +++--- ...config-tree-boot-and-transport-layering.i18n.yaml | 4 ++-- ...24-web-config-tree-boot-and-transport-layering.md | 8 ++++---- ...web-config-tree-boot-and-transport-layering.zh.md | 8 ++++---- .../2026-08-05-profile-plugin-bundles.i18n.yaml | 4 ++-- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...08-09-headless-direct-core-entry-point.i18n.yaml} | 6 +++--- ...> 2026-08-09-headless-direct-core-entry-point.md} | 12 ++++++------ ...026-08-09-headless-direct-core-entry-point.zh.md} | 8 ++++---- ...026-07-20-error-cause-chain-diagnostics.i18n.yaml | 4 ++-- .../2026-07-20-error-cause-chain-diagnostics.md | 2 +- .../2026-07-20-error-cause-chain-diagnostics.zh.md | 2 +- .../feature/2026-06-24-workspace-context.i18n.yaml | 2 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../feature/2026-07-16-harness-level-loop.i18n.yaml | 4 ++-- .../feature/2026-07-16-harness-level-loop.md | 2 +- .../feature/2026-07-16-harness-level-loop.zh.md | 2 +- .../2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../feature/2026-07-16-persistent-pty-sessions.md | 2 +- .../feature/2026-07-16-persistent-pty-sessions.zh.md | 2 +- .../feature/2026-07-19-human-goal-command.i18n.yaml | 4 ++-- .../feature/2026-07-19-human-goal-command.md | 2 +- .../feature/2026-07-19-human-goal-command.zh.md | 2 +- .../2026-07-19-plugin-command-registration.i18n.yaml | 4 ++-- .../2026-07-19-plugin-command-registration.md | 6 +++--- .../2026-07-19-plugin-command-registration.zh.md | 6 +++--- .../2026-07-21-local-instruction-overlay.i18n.yaml | 2 +- .../feature/2026-07-21-local-instruction-overlay.md | 2 +- .../2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../2026-07-24-web-session-model-selector.zh.md | 4 ++-- ...-08-07-default-model-follows-the-picker.i18n.yaml | 4 ++-- .../2026-08-07-default-model-follows-the-picker.md | 4 ++-- ...2026-08-07-default-model-follows-the-picker.zh.md | 4 ++-- .../2026-08-08-dsh-run-headless-command.i18n.yaml | 4 ++-- .../feature/2026-08-08-dsh-run-headless-command.md | 2 +- .../2026-08-08-dsh-run-headless-command.zh.md | 2 +- ...26-08-08-user-explicit-skill-invocation.i18n.yaml | 4 ++-- .../2026-08-08-user-explicit-skill-invocation.md | 4 ++-- .../2026-08-08-user-explicit-skill-invocation.zh.md | 4 ++-- .../2026-07-05-uniform-agent-note-format.i18n.yaml | 2 +- .../process/2026-07-05-uniform-agent-note-format.md | 2 +- ...07-19-remove-generated-agent-note-index.i18n.yaml | 2 +- .../2026-07-19-remove-generated-agent-note-index.md | 2 +- .../2026-07-22-product-first-root-readme.i18n.yaml | 2 +- .../process/2026-07-22-product-first-root-readme.md | 4 ++-- ...-08-03-package-anchored-subsystem-pages.i18n.yaml | 4 ++-- .../2026-08-03-package-anchored-subsystem-pages.md | 2 +- ...2026-08-03-package-anchored-subsystem-pages.zh.md | 2 +- ...2026-07-23-acp-automation-only-protocol.i18n.yaml | 2 +- .../2026-07-23-acp-automation-only-protocol.md | 2 +- .../2026-08-04-remove-tui-package.i18n.yaml | 2 +- .../simplification/2026-08-04-remove-tui-package.md | 4 ++-- .../2026-08-08-remove-cli-demo.i18n.yaml | 2 +- .../simplification/2026-08-08-remove-cli-demo.md | 2 +- apps/cli/src/profile-boot.ts | 2 +- docs/architecture.i18n.yaml | 2 +- docs/architecture.md | 4 ++-- docs/capability-seams.i18n.yaml | 4 ++-- docs/capability-seams.md | 4 ++-- docs/capability-seams.zh.md | 4 ++-- docs/cordis-tutorial/07-into-the-harness.i18n.yaml | 4 ++-- docs/cordis-tutorial/07-into-the-harness.md | 2 +- docs/cordis-tutorial/07-into-the-harness.zh.md | 2 +- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- examples/acp-agent/composition.md | 2 +- examples/headless-agent/README.i18n.yaml | 2 +- examples/headless-agent/README.md | 2 +- packages/bundle/base/cordis.patch.yml | 2 +- packages/client/ui-skill/README.i18n.yaml | 4 ++-- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 4 ++-- packages/core/README.i18n.yaml | 2 +- packages/core/README.md | 4 ++-- packages/core/agent-default-model/README.i18n.yaml | 2 +- packages/core/agent-default-model/README.md | 6 +++--- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-default-model/src/index.ts | 2 +- packages/core/agent/src/model-selection.ts | 4 ++-- packages/examples/README.i18n.yaml | 4 ++-- packages/examples/README.md | 6 +++--- packages/examples/README.zh.md | 4 ++-- packages/examples/acp-demo/README.i18n.yaml | 4 ++-- packages/examples/acp-demo/README.md | 2 +- packages/examples/acp-demo/README.zh.md | 2 +- packages/examples/acp-demo/src/index.ts | 4 ++-- packages/examples/agent-spine-demo/README.i18n.yaml | 4 ++-- packages/examples/agent-spine-demo/README.md | 12 ++++++------ packages/examples/agent-spine-demo/README.zh.md | 12 ++++++------ packages/examples/agent-spine-demo/src/index.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 6 +++--- packages/feedback/command-feedback/README.i18n.yaml | 2 +- packages/feedback/command-feedback/README.md | 2 +- packages/hooks/hooks-claude/src/index.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 4 ++-- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/index.ts | 2 +- packages/plan/plan-mode/README.i18n.yaml | 2 +- packages/plan/plan-mode/README.md | 2 +- packages/scaffold/helper/src/features/builtin/app.ts | 6 +++--- packages/scaffold/helper/src/features/feature.ts | 4 ++-- packages/scaffold/helper/src/project/types.ts | 2 +- packages/scaffold/helper/tests/project.spec.ts | 2 +- .../self-modification/tool-cordis/src/api-catalog.ts | 2 +- .../self-modification/tool-cordis/src/sandbox.ts | 2 +- scripts/gen-doc-graphs.ts | 6 +++--- 121 files changed, 211 insertions(+), 211 deletions(-) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.i18n.yaml => 2026-08-09-headless-direct-core-entry-point.i18n.yaml} (55%) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.md => 2026-08-09-headless-direct-core-entry-point.md} (76%) rename .agents/notes/implemented/architecture/{2026-08-09-headless-direct-core-front-door.zh.md => 2026-08-09-headless-direct-core-entry-point.zh.md} (94%) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index b33a36665d..56eeb36b4d 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md -README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0 +README.md: d3a8943a78238d974d54028e38b773e932429b0e README.zh.md: 4b3a1ee57ea61a8e8ba4d01cf7c719bbf8440e30 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 3cfbb51547..d3a8943a78 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the front door and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). +One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the entry point and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 02d531f642..0c81565bdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 77f8e379e2b07ecf4e67fa7197752543cfedd6dd -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: ce4a04a66e345834bc2b16b743be89dc7a0b9424 +2026-07-15-llm-model-catalog-and-acp-selection.md: bfd17c73b01319c10d5dc03333b3c726db5d6f33 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aeddada5591bb2da2c0861acc368516eff148172 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 77f8e379e2..bfd17c73b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -26,17 +26,17 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch `dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. -### Per-session selection in the front door +### Per-session selection in the front end -A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. +A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. -The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. +The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index ce4a04a66e..aeddada559 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -26,17 +26,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 `dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 -### 前门内的会话级选择 +### 前端内的会话级选择 -选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 +选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前门拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 -请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 +请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 87c65807b8..d47c50cdb7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 7997a682c8745f7b3d0a9721acfa9355603f9b2e -2026-07-19-gui-layering-and-rpc-protocol.zh.md: cb36b5cb725128e1c5067e5e69e52aa851668e52 +2026-07-19-gui-layering-and-rpc-protocol.md: 705b0df5feb5fedaae4d198aed71758b54586e93 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: b28b08b4b9da058e01af62e610d4e226d794151f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 7997a682c8..705b0df5fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session front door](2026-08-09-headless-direct-core-front-door.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch. +The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(entry-point plugin)` directly, and wear no fetch. ## Message protocol @@ -242,7 +242,7 @@ Every client shape consumes one contract: adding a unary method is a five-step m |---|---| | Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local front door with no client boundary and uses the public Agent/Session seams rather than a client command plane | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index cb36b5cb72..b28b08b4b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的前门](2026-08-09-headless-direct-core-front-door.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(前门插件)` 挂载,不套 fetch。 +现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不套 fetch。 ## 消息协议 @@ -240,7 +240,7 @@ export type ResponseValue = |---|---| | 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地前门,使用公开的 Agent/Session seam,而不是 client 命令面 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index ff517555db..e7fd7799f0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 496499a691dbca012e5e953cbb6eb1d0bf25b635 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c4bed7b730cf37e1d90750f5c93fbccb920ced21 +2026-07-24-web-config-tree-boot-and-transport-layering.md: 92ec665acc745e61f656bd0e57454ad266b722f9 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7cd96ad9e52c19a005e6bff356dc5159ad3a31bc diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 496499a691..92ec665acc 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,23 +16,23 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless front door](2026-08-09-headless-direct-core-front-door.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's front-door-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. ## Consequences - Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted. -- [Headless is a direct core front door](2026-08-09-headless-direct-core-front-door.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. +- [Headless is a direct core entry point](2026-08-09-headless-direct-core-entry-point.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract. - A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct front doors | +| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | | Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index c4bed7b730..7cd96ad9e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,23 +16,23 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 前门](2026-08-09-headless-direct-core-front-door.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定前门的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 ## 后果 - 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。 -- [Headless 是直接 core 前门](2026-08-09-headless-direct-core-front-door.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 +- [Headless 是直接 core 入口](2026-08-09-headless-direct-core-entry-point.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。 - 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。 ## 考虑过的替代方案 | 弃案 | 一行理由 | |---|---| -| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接前门 | +| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | | 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 | diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index fcac71abf4..938e802716 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 8b5ab0c99282f6868fc3f70781e618af9a317c09 -2026-08-05-profile-plugin-bundles.zh.md: 2a685d68b3de9210488e26f8e6dd93dfc07f956c +2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b +2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 8b5ab0c992..2924b3cb44 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core front door](2026-08-09-headless-direct-core-front-door.md) owns the headless composition contract. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 2a685d68b3..b228703401 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 前门](2026-08-09-headless-direct-core-front-door.md)负责 headless 组合约定。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 [`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 20b200713a..b851627050 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md -2026-08-09-headless-direct-core-front-door.md: f4604329a9276448a0021bb749b09e8c1b82e3c1 -2026-08-09-headless-direct-core-front-door.zh.md: aaa1289894bf3c69b39aa863493dffdc3437ad01 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a +2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md similarity index 76% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index f4604329a9..49afe2993d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -1,22 +1,22 @@ -# Agent Note: headless is a direct core front door +# Agent Note: headless is a direct core entry point Status: implemented -English | [中文](2026-08-09-headless-direct-core-front-door.zh.md) +English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, empty stderr on success, and no listening port. A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. -The direct front door still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. +The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. -`headless-runner` is a direct core front door. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. +`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy front doors consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. @@ -31,7 +31,7 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot front door has no client boundary. | +| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | | Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | | Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md similarity index 94% rename from .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md rename to .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index aaa1289894..73c1cbe5ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-front-door.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -2,13 +2,13 @@ Status: implemented -[English](2026-08-09-headless-direct-core-front-door.md) | 中文 +[English](2026-08-09-headless-direct-core-entry-point.md) | 中文 ## 问题 `headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,成功时 stderr 为空,并且不打开监听端口。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 -直接前门仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 +直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接前门与 ApiProxy 前门均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 @@ -31,7 +31,7 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性前门没有客户端边界。 | +| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | | 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | | 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml index c9581fcbfb..9d94121626 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md -2026-07-20-error-cause-chain-diagnostics.md: 32716b5a68b3b73bded47633eca95995cdbfc586 -2026-07-20-error-cause-chain-diagnostics.zh.md: 9911c32b6d68c1f5569a1fceadb65e23c6586594 +2026-07-20-error-cause-chain-diagnostics.md: b80dd08d79a57738a6eef2f8638b0336ca80d4bf +2026-07-20-error-cause-chain-diagnostics.zh.md: 914e983d7ea81eef8bca8fd5aa3791f2f44d4080 diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md index 32716b5a68..b80dd08d79 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md @@ -9,7 +9,7 @@ English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md) A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end: 1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic boundary in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line. -2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. +2. The readline entry point (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md index 9911c32b6d..914e983d7e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.zh.md @@ -9,7 +9,7 @@ Status: implemented TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同: 1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断边界都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。 -2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 +2. readline 入口(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 32c80847bb..e60442bb37 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-24-workspace-context.md -2026-06-24-workspace-context.md: 53a580aee50752b7c6daeff5caa42ba6409c8885 +2026-06-24-workspace-context.md: e7a3724847b9dc8cfad11e87b2c96a3ef442bcba 2026-06-24-workspace-context.zh.md: 39c3f52da10b7299301d10bd8b78330cbae8e19c diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 53a580aee5..e7a3724847 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -78,7 +78,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. +Workspace guidance is isolated per session and shared by the demo entry points, Web Host, and every tool presentation mode. Initial, nested, and changed instructions are durable and replayable. The generic session/agent context contract carries typed source data through inbox-staged and durably entered user messages without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml index b7181bfba1..af11b3f3f9 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-harness-level-loop.md -2026-07-16-harness-level-loop.md: e84a5738a55988d3b968eac829d9daaf10d7e304 -2026-07-16-harness-level-loop.zh.md: d7cda56d66d4456ac5c3c7d55bb238e85fcacbc5 +2026-07-16-harness-level-loop.md: e5dd94fbf76e27f0843666f29622969635ef2fc3 +2026-07-16-harness-level-loop.zh.md: 773b5ff1f4b07206d0d817c29774435ce24407b4 diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md index e84a5738a5..e5dd94fbf7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md @@ -70,7 +70,7 @@ The human UX follows the compact Codex shape in the [public OpenAI Codex TUI dis The model receives only `get_goal`, `create_goal`, and `update_goal`. It may create a goal when a direct human request clearly asks for substantial multi-round work, and it may infer that intent in any language. It must not turn routine one-turn work into a goal. Code requires a direct human message in the current live root-agent turn; semantic interpretation remains model judgment. An autonomous goal round may report `complete` or `blocked` for the exact current goal round but cannot edit, pause, resume, or replace the human objective. -TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC front doors do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. +TUI mounts the shared command registry and complete goal stack by default and exposes `/goal` through one producer. ACP mounts the goal domain, model tools, and same-session driver but deliberately omits the human command plane. Every effective registered command is discoverable and invocable through every composed command adapter; a plugin incompatible with an application omits its command producer from that composition rather than relying on registry-level surface masks. The UI-less agent spine is opt-in so one-shot callers do not silently become multi-round operations. The headless CLI and JSON-RPC entry points do not consume the command plane; ordinary human text can still authorize model goal tools when that stack is composed. ### Fresh-agent Ralph execution diff --git a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md index d7cda56d66..773b5ff1f4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md @@ -70,7 +70,7 @@ Goal Round 驱动器为每个特定的实时 agent 至多拥有一个待定预 模型只接收 `get_goal`、`create_goal` 和 `update_goal`。当直接人类请求清楚要求大量多 Round 工作时,模型可以创建目标,并且可以从任何语言推断该意图。它不得把日常单 Turn 工作变成目标。代码要求当前实时根 agent Turn 中有一条人类直接发送的消息;语义解释仍是模型判断。自治目标 Round 可以为确切的当前 Goal Round 报告 `complete` 或 `blocked`,但不能编辑、暂停、恢复或替换人类目标。 -TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 前端不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 +TUI 默认挂载共享命令注册表和完整目标栈,并通过一个生产方暴露 `/goal`。ACP(Agent Client Protocol)挂载目标领域、模型工具和同会话驱动器,但有意省略人类命令平面。每条有效已注册命令都能被每个已组合的命令适配器发现和调用;若插件与某应用不兼容,该应用组合会省略其命令生产方,而不是依赖注册表层面的表面掩码。无 UI 的 agent 主干要求显式选择加入,以免单次调用方静默变成多 Round 操作。无头 CLI(命令行界面)与 JSON-RPC 运行入口不消费命令平面;挂载目标栈后,普通人类文本仍可授权模型目标工具。 ### 全新 agent Ralph 执行 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 146601b28d..32fa522443 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: f27d799574e61d92123c9ca4e31c5c2a9d3229b4 -2026-07-16-persistent-pty-sessions.zh.md: 0c23619faf00794c2c4e7b3f85ef961faddd99e0 +2026-07-16-persistent-pty-sessions.md: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 +2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index f27d799574..fb9cd06bad 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -152,7 +152,7 @@ The package ships concise tool guidance explaining persistent state, owner isola **Include TUI sequences and BEL handling.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational. -**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. +**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current long-lived entry points already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here. ## Verification diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 0c23619faf..55a5848c1a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -152,7 +152,7 @@ plugins: **包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。 -**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 +**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前长驻的运行入口已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml index 5b39ad725e..b45809724c 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-human-goal-command.md -2026-07-19-human-goal-command.md: 5fdd80f7423b80e84e58f7379130ee59a2e8a723 -2026-07-19-human-goal-command.zh.md: 89d29497abfc758ff8373d272aa8620ca1bcfcc2 +2026-07-19-human-goal-command.md: d68e4025a4d37d07211f15ddfc6069bc7c637124 +2026-07-19-human-goal-command.zh.md: dbda35c731ccdc7aff2d4213a3df8b78070319df diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md index 5fdd80f742..d68e4025a4 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.md @@ -68,5 +68,5 @@ The producer suite uses the real command registry, goal service, agent registry, - The portable command contract has no modal editor or confirmation interaction; inline edit and explicit clear are intentional until a general cross-surface interaction primitive exists. - `/goal` does not accept a per-command round cap. Deployment config owns the default, and the authorized model tool can edit a cap after direct human instruction. - TUI renders portable plain text rather than a continuously updated goal status widget. Reconnectable command output and adapter-specific status indicators are deferred. -- The ACP automation server, headless CLI, and JSON-RPC front doors do not consume the command registry. +- The ACP automation server, headless CLI, and JSON-RPC entry points do not consume the command registry. - The command observes and mutates state but does not certify completion or blockers. Evaluator-backed certification remains deferred to a separate policy layer with an explicit authority and isolation contract. diff --git a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md index 89d29497ab..dbda35c731 100644 --- a/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-human-goal-command.zh.md @@ -68,5 +68,5 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者 - 可移植命令约定没有模态编辑器或确认交互;在出现通用跨界面交互原语之前,行内编辑与明确清除是有意选择。 - `/goal` 不接受逐命令 Round 上限。部署配置拥有默认值;得到直接人类指示后,已授权模型工具可以编辑上限。 - TUI 渲染可移植纯文本,而不是持续更新的目标状态组件。可重连命令输出和适配器专用状态指示器予以延期。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC 前端不消费命令注册表。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC 运行入口不消费命令注册表。 - 该命令观察并改变状态,但不认证完成或阻塞。基于评估器的认证延期到具有明确权限与隔离约定的独立策略层。 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml index 26dde53462..83e3319f6d 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md -2026-07-19-plugin-command-registration.md: c8f0f2772a41948e9eb257a16f40194518c568f9 -2026-07-19-plugin-command-registration.zh.md: a8f7a3d1e7947aeb41344a72106cee55d249010c +2026-07-19-plugin-command-registration.md: 76feba84492bd6b245246a6f1fb1e8f68d555684 +2026-07-19-plugin-command-registration.zh.md: e89cb79ba1e15dfefa527fe7bc37c09c3449f5b4 diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md index c8f0f2772a..76feba8449 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md @@ -12,7 +12,7 @@ A shared mechanism must remain a UI concern rather than a model tool or agent-lo ## Decision -`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front door; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. +`@deepseek-ai/dsh-commands` in `packages/interaction/commands/` is the product command registry. The TUI app bundle mounts it beside its consuming front end; the [automation-only ACP app](../simplification/2026-07-23-acp-automation-only-protocol.md) and the executor-less, UI-less agent spine omit it. TUI injects the service, while command producers depend only on the registry and any domain they operate. ### Registry contract @@ -50,7 +50,7 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - **Keep adapter-local switches** — rejected because optional plugins cannot contribute discovery and behavior without editing the TUI. - **Represent human commands as model tools** — rejected because discovery and direct invocation are human UI behavior; routing through the model adds latency, token cost, and reinterpretation. -- **Put the registry in the core agent spine** — rejected because UI-less front doors do not consume it, while TUI can compose it explicitly. +- **Put the registry in the core agent spine** — rejected because UI-less entry points do not consume it, while TUI can compose it explicitly. - **Make `dsh-agent-loop` inject commands** — rejected because the loop does not execute or discover human commands. Agent-scoped producers declare the UI dependency in a child plugin instead. - **Attach adapter masks to each definition** — rejected because support is a composition fact, not command-domain state. Every composed adapter exposes a registered command; an incompatible plugin omits registration in that deployment. - **Send unknown slash input to the model** — rejected because typoed or unavailable direct actions must fail predictably rather than change execution planes. @@ -68,4 +68,4 @@ TUI tests exercise all migrated built-ins, live plugin discovery, help/autocompl - Input metadata is limited to an unstructured text hint. Typed forms, argument schemas, and completion providers remain command-owned or require a later registry or consumer extension. - Generic command output is live-only and is not reconstructed after TUI restart. - Registry cancellation stops awaiting immediately, but external work stops only when a handler cooperates with its signal. -- The ACP automation server, headless CLI, and JSON-RPC SDK front doors do not expose the command plane; only TUI consumes it. +- The ACP automation server, headless CLI, and JSON-RPC SDK entry points do not expose the command plane; only TUI consumes it. diff --git a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md index a8f7a3d1e7..e89cb79ba1 100644 --- a/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md @@ -12,7 +12,7 @@ TUI 拥有斜杠命令。如果命令名、帮助文本、自动补全、分派 ## 决策 -位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的入口旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 +位于 `packages/interaction/commands/` 的 `@deepseek-ai/dsh-commands` 是产品命令注册表。TUI 应用 bundle(组合包)把它挂载在消费该服务的前端旁;[仅面向自动化的 ACP(Agent Client Protocol)应用](../simplification/2026-07-23-acp-automation-only-protocol.md)和无执行器、无 UI 的智能体 spine(主干)都省略该服务。TUI 注入该服务,命令生产者只依赖注册表及其操作的领域。 ### 注册表约定 @@ -50,7 +50,7 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - **保留适配器本地 switch**——不予采纳,因为可选插件无法贡献发现与行为,除非修改 TUI。 - **把人类命令表示为模型工具**——不予采纳,因为发现与直接调用属于人类 UI 行为;经由模型路由会增加延迟、token 成本和重新解释。 -- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 前端不消费它,而 TUI 可以显式组合它。 +- **把注册表放入核心智能体主干**——不予采纳,因为无 UI 运行入口不消费它,而 TUI 可以显式组合它。 - **让 `dsh-agent-loop` 注入 commands**——不予采纳,因为循环不执行也不发现人类命令。智能体作用域生产者改为在子插件中声明 UI 依赖。 - **为每个定义附加适配器掩码**——不予采纳,因为支持能力是组合事实,而不是命令领域状态。每个已组合适配器都暴露已注册命令;不兼容插件不会在该部署中注册。 - **把未知斜杠输入发送给模型**——不予采纳,因为输入错误或不可用的直接操作必须可预测地失败,而不能改变执行平面。 @@ -68,4 +68,4 @@ TUI 测试覆盖全部迁移后的内置命令、实时插件发现、帮助与 - 输入元数据仅限非结构化文本提示。类型化表单、参数模式和补全提供器仍由命令拥有,或需要后续注册表或消费方扩展。 - 通用命令输出仅实时存在,TUI 重启后不会重建。 - 注册表取消会立即停止等待,但外部工作只有在处理器配合信号时才会停止。 -- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 前端不暴露命令平面;只有 TUI 消费它。 +- ACP 自动化服务器、无头 CLI 与 JSON-RPC SDK 运行入口不暴露命令平面;只有 TUI 消费它。 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml index ee932dd716..461e95c391 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md -2026-07-21-local-instruction-overlay.md: 3c7b2141b0515b5e667be4add6ad765e26c88cd8 +2026-07-21-local-instruction-overlay.md: fb5f916d426595a80bbbaa4192e4a3975b922b8a 2026-07-21-local-instruction-overlay.zh.md: c97ed04607f497d829da0e904c248836252c73f7 diff --git a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md index 3c7b2141b0..fb5f916d42 100644 --- a/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md +++ b/.agents/notes/implemented/feature/2026-07-21-local-instruction-overlay.md @@ -26,7 +26,7 @@ The base and local candidates in one directory must stay independent across base **Keep it opt-in through `instructionFileCandidates`.** Rejected: one directory has a single winner, so a `.local.` name added to that list shadows the base file rather than supplementing it. The packages guidance to keep opt-ins out of shipped defaults is outweighed here by strong prior art and the user-facing expectation that `.local.` files are always read. -**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever front door remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. +**Default at the product `cordis.yml` level instead of the plugin schema.** Rejected: it would enable `.local.` only for whichever entry point remembered to opt in, splitting behavior across TUI/ACP/headless and duplicating a value that belongs beside the existing candidate default. **Reuse the bare directory as the scope key for base and local files.** Rejected: base and local files in one directory would collide in every scope-keyed map, so a change to one would suppress or overwrite the other. A distinct scope key per candidate keeps them independent without widening the persisted metadata shape. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 626d98b3c8..da84a269ed 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 78017b14a806f8e609a85e340094cd2a349d47e1 -2026-07-24-web-session-model-selector.zh.md: 87a342721f5e47f5bcedfafb578eb6916101d2f5 +2026-07-24-web-session-model-selector.md: e6a96ac62f69a3bd312f61cc920caa259d2dc5b0 +2026-07-24-web-session-model-selector.zh.md: 5a0245359d69ac6e59a20dc3276b9411c4b25e23 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 78017b14a8..e6a96ac62f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-session-model-selector.zh.md) ## Problem -The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front doors. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. +The Web conversation needs a visible, mutable session model selection sourced from the Host. Copying TUI presentation or hardcoding DeepSeek models in the browser would split model discovery and step-boundary semantics across front ends. A switch made while a response is running also needs one atomic boundary: prompt variables and request routing cannot observe different selections. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 87a342721f..5a0245359d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前门中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 +Web 对话需要一项由 Host 提供、可见且可更改的会话模型选择。如果照搬 TUI 的呈现方式,或在浏览器中硬编码 DeepSeek 模型,就会让模型发现逻辑和步骤边界语义分散到不同前端中。响应运行期间发生的切换还需要一个原子边界:提示词变量与请求路由不能观测到不同的选择。 ## 决策 Web Host 为每个新建或恢复的 Agent 安装 `ModelSelection`。如果会话已经使用过模型,提供方/模型/推理(reasoning)选择来自最新的 `request/header`;否则来自 `ctx.agentDefaultModel`。`session.selectModel` 会赋值会话级选择,提示词组装则将它与请求路由一并捕获,因此运行中步骤发生的切换会应用于下一个组装步骤。下一个实际采用的选择通过完整的 `request/header` 快照持久化;尚未进入请求的选择则仅保存在当前进程中。 -会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前门对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录的 surface,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 +会话 RPC 领域公开 `session.models` 模型目录与 `session.selectModel`。该目录从 LLM(大语言模型)注册表动态构建,并按提供方分组;每个已列出模型的精确元数据还会加入由适配器持有的推理强度 ID、名称、说明和可选默认值。各提供方的目录与精确元数据会按提供方并发加载,且彼此独立失败,因此成功加载的分组仍可与可重试的失败记录一同使用。模型是否位于目录仅供参考:`session.models.current` 独立返回,即使不在任何分组中也仍然可以路由,但提供方停止公布该模型后,Host 不会合成未列出行。两个前端对这一状态给出不同回答:TUI 把未列出的当前模型渲染为独立一行,Web 则显示未设置状态的触发器标签并要求选择替代模型。Web 是编辑目录所在的前端,因此缺席的目录行代表一项待作出的选择;TUI 只从现有行中选择。显示未设置标签的 Web composer 仍可以使用当前可路由选择发送消息。精确解析决定提供方/模型组合与显式推理强度是否可用。选择操作通过 `resolveCallConfig` 拒绝不支持的推理强度 ID,并在赋值该选择前具体化适配器配置的默认值。 浏览器中的 `ModelService` 为每个实时会话持有一个 `ModelDirectory`。其快照包含当前完整的 `ModelSelection`、分组目录、提供方失败记录、操作错误,以及 `idle`、`loading`、`ready`、`selecting`、`error` 状态。挂载时会预先填充触发器标签,此后每次打开菜单都会刷新目录。目录与选择调用共用操作代次,防止较早响应覆盖较新结果;连接重置会先丢弃当前进程中的投影,再恢复 Host 选择。失败时保留先前的选择和可用分组。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 684931decb..de5133b527 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: ed7e7a424d2cacadea890506fd9150ffdf7a993c -2026-08-07-default-model-follows-the-picker.zh.md: 523c6f917dedf21c726ce5631226ba545126c757 +2026-08-07-default-model-follows-the-picker.md: 2a3ada55486345c0f58f0767bed5a93ecba04b88 +2026-08-07-default-model-follows-the-picker.zh.md: 08fecc6ec9b177f6424ada3172c67018ac72baea diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index ed7e7a424d..2a3ada5548 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -6,13 +6,13 @@ English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) ## Problem -A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent front doors cannot share it without depending on Host or duplicating state. +A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent entry points cannot share it without depending on Host or duplicating state. Reasoning effort makes the persistence shape significant: a model selection without an effort must clear a stored effort, or the next Agent may apply an effort that its selected model does not accept. ## Decision -`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is front-door-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. +`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 523c6f917d..08fecc6ec9 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的前门只有依赖 Host 或复制状态才能共享它。 +会话模型选择器与部署默认值是同一项偏好的两个层次。如果选择器只影响其所在会话,下一个空白会话可能选择不同模型,用户却没有途径使默认值与选择器一致。如果默认值位于 Host 网关内部,直接创建 Agent 的入口只有依赖 Host 或复制状态才能共享它。 推理强度使持久化形态成为约定的一部分:不含强度的模型选择必须清除已存强度,否则下一个 Agent 可能会采用所选模型不接受的强度。 ## 决定 -`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定前门,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 +`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;缺席值由此清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml index f4e308c8f2..730f57e681 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: 566eeb5b2a09a0d07d72a68e4a5f449d2822e708 -2026-08-08-dsh-run-headless-command.zh.md: 177410e783a37026940829da5f81343ddc61cb29 +2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed +2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md index 566eeb5b2a..ed095f4077 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` is a distinct `DshInvocation` member. The generic profile invocation carries no task state and accepts no positional arguments. Both dispatch paths use `runProfile`: profile boot omits `task`, while `run` supplies it. A one-shot profile without `headless-runner` fails through the composed-row check, and profile boot containing that row without a task points to `dsh run --profile ""`. -The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. +The [profile plugin bundle decision](../architecture/2026-08-05-profile-plugin-bundles.md) owns composition. [Headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md) owns the execution contract: one fresh persisted Session, final assistant text on stdout, completed/non-completed exit mapping, empty stderr on success, no listening port, and bounded signal shutdown after Agent quiescence and Session flush. The `run` verb belongs only to one-shot task execution. Application-file launch requires a distinct command name. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md index 177410e783..89d54e3557 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -22,7 +22,7 @@ dsh run [--profile ] [--patch ...] `RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不携带任务状态,也不接受位置参数。两条分派路径都使用 `runProfile`:profile 启动省略 `task`,而 `run` 提供该字段。缺少 `headless-runner` 的一次性 profile 会触发组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 -[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 前门](../architecture/2026-08-09-headless-direct-core-front-door.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 +[profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)负责组合。[Headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)负责执行约定:一个新的持久化会话、stdout 上的最终 assistant 文本、completed/非 completed 的退出状态映射、成功时为空的 stderr、无监听端口,以及 Agent 完全停稳且会话 flush 后的有界信号关闭。 `run` 动词只负责一次性任务执行。应用文件启动需要不同的命令名。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 005b3cad67..b44a25790d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 74d9f01f191005db6d3d283a3c56f5ee664447f8 -2026-08-08-user-explicit-skill-invocation.zh.md: 0f7c9e1261dda796d988c17ddf7199b74be4133e +2026-08-08-user-explicit-skill-invocation.md: a7c2c15703af318cb4112f2d3dfda698bc5e3bc2 +2026-08-08-user-explicit-skill-invocation.zh.md: a8d685e5eb12766bebddebb7f5993579cf08531e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 74d9f01f19..a7c2c15703 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -10,14 +10,14 @@ A `disable-model-invocation: true` skill is user-only by design: it never enters ## Decision -User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every entry point: - `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. - Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. - The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. - The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. -Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every entry point from implementing recognition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index 0f7c9e1261..a8d685e5eb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -10,14 +10,14 @@ Status: implemented ## 决策 -用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种运行入口一致: - `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall(瀑布式事件)会把携带目录的列表交给它来扩展。 - 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 - 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 - 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,由注入和 `skill` 工具结果共用,二者内容逐字相同,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 -同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种运行入口免于自行实现识别。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml index ae738e3fad..95b38715c2 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md -2026-07-05-uniform-agent-note-format.md: 06082251c1b96c90ed470d84224662e00e29791b +2026-07-05-uniform-agent-note-format.md: c05d81c700b81f0d619173a261f405b1cc039df1 2026-07-05-uniform-agent-note-format.zh.md: 3daa686b64b31ee2638b25dd4c42e5d8172f1d97 diff --git a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md index 06082251c1..c05d81c700 100644 --- a/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md +++ b/.agents/notes/implemented/process/2026-07-05-uniform-agent-note-format.md @@ -23,7 +23,7 @@ The whole corpus was normalized in the same change that defined the format — t - **A bare `# ` H1** — rejected: the `Agent Note: ` prefix self-describes the genre when a file is read outside its tree, and the format gate prevents it from drifting. - **`## What we give up` as the implemented closer** (the README's own phrase for what an Agent Note records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well. - **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here. -- **A standalone `FORMAT.md` contract file** — rejected because one front door carrying layout, classification, and format is easier to discover and maintain than two contract files. +- **A standalone `FORMAT.md` contract file** — rejected because one entry point carrying layout, classification, and format is easier to discover and maintain than two contract files. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml index ce16a025cc..5c5386b2dd 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md -2026-07-19-remove-generated-agent-note-index.md: ee85ec0757d5924f5784c43a50003eb96e0a9531 +2026-07-19-remove-generated-agent-note-index.md: 652ac72afe69284240667e61af1f3fbfcb182ddb 2026-07-19-remove-generated-agent-note-index.zh.md: 6e1967fbde0c6ac59bbc3a184dad68c989efadbc diff --git a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md index ee85ec0757..652ac72afe 100644 --- a/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md +++ b/.agents/notes/implemented/process/2026-07-19-remove-generated-agent-note-index.md @@ -12,7 +12,7 @@ The centralized chronological list adds little discovery value beyond browsing t ## Decision -The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated front door and contract, while ordinary tree navigation and repository search provide discovery. +The lifecycle/class filesystem tree is the Agent Note inventory. [README.md](../../README.md) remains the curated entry point and contract, while ordinary tree navigation and repository search provide discovery. `scripts/agent-note-tree.ts` owns the closed lifecycle/class sets and structural walker. `verify-agent-note-classification` validates that tree and rejects the legacy homes and a root `INDEX.md`; it does not render or freshness-check a centralized list. diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index d85aa1fe0c..61942db772 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e +2026-07-22-product-first-root-readme.md: 00f6084da9e83135c881abfef21b0b249cd4e30a 2026-07-22-product-first-root-readme.zh.md: 8ef6f4b99ca2c935183a225b6357d2d128edb3b0 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 32542a4501..00f6084da9 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-product-first-root-readme.zh.md) ## Problem -The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. +The root README is the repository's product entry point. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works. ## Decision @@ -26,7 +26,7 @@ Detailed package and service inventories remain at their owning documentation. T **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer entry point have different navigation and maintenance needs. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml index b124dbfd9e..e305a9d10e 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md -2026-08-03-package-anchored-subsystem-pages.md: f429f3d41c1f152e83faeb12c379d221627e767f -2026-08-03-package-anchored-subsystem-pages.zh.md: 3a56fa39357591197606a28b49cf0eac8f58963e +2026-08-03-package-anchored-subsystem-pages.md: 2a47f35e9d3755286bed7d42f59fd21e30f9148d +2026-08-03-package-anchored-subsystem-pages.zh.md: 53216a430b979f0612f256aaec9774b88fe14bbd diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md index f429f3d41c..2a47f35e9d 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md @@ -14,7 +14,7 @@ Every `docs/subsystems/` page anchors to the package or package group that decla Every type a generated signature references must resolve somewhere in the folder: the agent ownership vocabulary moved from the generator's `TYPE_LINK_EXEMPTIONS` into `LINK_MAP → core.md`, so exemptions are reserved for genuinely service-local or vendored shapes. Each pasted declaration has one home (`SessionEvent` lives on [session.md](../../../../docs/subsystems/session.md); core.md summarizes and links). -Every `packages/<group>/README.md` pair is a thin front door in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. +Every `packages/<group>/README.md` pair is a thin entry point in one shape: a why-first intro paragraph, a package table (Package / Role / ctx key), and a closing pointer to the owning subsystems page. Load-bearing prose that outgrows that shape relocates to the owning subsystems page rather than being deleted. The [subsystems README](../../../../docs/subsystems/README.md) indexes every page in the folder on both language sides; `scripts/project-doc-site.spec.ts` enforces one table row per page, so a page added by a later PR (or absorbed in a merge) cannot silently miss the index. diff --git a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md index 3a56fa3935..53216a430b 100644 --- a/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md +++ b/.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.zh.md @@ -14,7 +14,7 @@ Status: implemented 生成签名引用的每个类型都必须能在目录中某处解析:agent 所有权词汇从生成器的 `TYPE_LINK_EXEMPTIONS` 移入 `LINK_MAP → core.md`,因此豁免只留给真正服务本地或 vendored 的形状。每个粘贴的声明只有一个家(`SessionEvent` 位于 [session.md](../../../../docs/subsystems/session.md);core.md 概括并链接)。 -每个 `packages/<group>/README.md` 配对都是统一形状的轻薄门面:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 +每个 `packages/<group>/README.md` 配对都是统一形状的精简入口:一段以「为什么」开头的介绍、一张包表格(包 / 角色 / ctx 键)、一个指向拥有方子系统页面的收尾指针。超出该形状的承重散文迁移到拥有方子系统页面,而非删除。 [子系统 README](../../../../docs/subsystems/README.md) 在两个语言侧索引目录中的每一页;`scripts/project-doc-site.spec.ts` 强制每页一行表格,因此后续 PR 新增(或合并吸收)的页面无法悄悄缺席索引。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index d09614a7fa..66e573773b 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: 29da3025251f9826c5780493bb2725b114a40601 +2026-07-23-acp-automation-only-protocol.md: 56c433acf59d3a0c4b5c8b6605e422d3c4efede2 2026-07-23-acp-automation-only-protocol.zh.md: bf326ce9f6fea8c55b842bc61e3d8521032ea4d1 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 29da302525..56c433acf5 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -46,7 +46,7 @@ Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiat ## Consequences -ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor front door. +ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. Automation clients receive complete committed text rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml index 73c3fb6e55..bcf543a344 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md -2026-08-04-remove-tui-package.md: f048a04db02b582d038ebf549999c0fda50e30d3 +2026-08-04-remove-tui-package.md: 19cc7d1a89a55bb57a69b9fce301f48f89384acd 2026-08-04-remove-tui-package.zh.md: be18cbd33cd4a2bb592de4e7986b2786da272d0f diff --git a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md index f048a04db0..19cc7d1a89 100644 --- a/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md +++ b/.agents/notes/implemented/simplification/2026-08-04-remove-tui-package.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-remove-tui-package.zh.md) Removing the implicit `dsh` terminal application left `@deepseek-ai/dsh-tui` without a shipped composition. The package still carried a terminal renderer, interactive command and question adapters, extension overlays, snapshot fixtures, a patched `pi-tui` dependency, and SDK scaffolding that advertised TUI as a supported application interface. Keeping that surface required maintaining a product-sized frontend whose only remaining consumer was the project generator itself. -The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI front doors, while the SDK continued to offer a terminal choice that no example or product command exercised. +The package also made the repository's supported application inventory misleading. Current runnable products use Web, ACP, JSON-RPC, or one-shot CLI entry points, while the SDK continued to offer a terminal choice that no example or product command exercised. ## Decision @@ -34,6 +34,6 @@ Repository searches and generated catalogs contain no TUI package, dependency pa ## Consequences -DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web front doors. +DeepSeek Harness has no terminal UI package or generated TUI application. Existing imports, `cordis.yml` rows, SDK `--interface=tui` requests, and projects that depend on the package fail instead of being translated. Web remains the shipped interactive surface; ACP, JSON-RPC, and one-shot CLI remain the non-Web entry points. The provider-neutral command, user-interaction, approval, tool-presentation, PTY, and session-projection capabilities remain available to other hosts. Reintroducing a terminal frontend requires a named product or deployment, an explicit package boundary, a concrete interaction provider, and assembled lifecycle and transcript acceptance for that frontend. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 9a88d14722..217265168d 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: c1153f5e9fcc89585e926f8f088829c849d1f6c1 +2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index c1153f5e9f..403e01f94c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two front doors also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 3e3918c2ab..e4a719379e 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -243,7 +243,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted front door can publish readiness before sibling rows + // settles: an inserted entry point can publish readiness before sibling rows // finish mounting. process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) process.on('SIGINT', () => { interrupt(130) }) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 911b25d0cc..22c74a441e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 90d64fb0ac62020e13a7ce995c8e3273a5a9f906 +architecture.md: ebf05397cb67cea336dd36a4d9416d43b6002d4e architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd diff --git a/docs/architecture.md b/docs/architecture.md index 90d64fb0ac..ebf05397cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope | -| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent front doors | +| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent entry points | | `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -164,7 +164,7 @@ Exceptions combine LLM Service Definition/Consumer roles, filesystem policy, web ### Bundles And Apps -`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC front doors ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC entry points ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Agent Presets diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 05186f3e56..085d993fef 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 05788eb8044f91e31c82dc4b78af0421e2b11030 -capability-seams.zh.md: ae182cfeeb1d7461122d791eedb818160a772e9b +capability-seams.md: 345e17c8c28bbe3465abd20639770e6331a70d11 +capability-seams.zh.md: 472aaabf4c992fdcd54fbe0e9a14cf9a82e6b803 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 05788eb804..345e17c8c2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -399,7 +399,7 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/self-modification/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | @@ -407,7 +407,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index ae182cfeeb..472aaabf4c 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -401,7 +401,7 @@ flowchart LR | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm)、[`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop)、[`tools`](../packages/core/tools)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop)、[`tool-ask-user`](../packages/interaction/tool-ask-user)、[`tool-bash`](../packages/bash/tool-bash)、[`tool-cordis`](../packages/self-modification/tool-cordis)、[`tool-fs`](../packages/fs/tool-fs)、[`tool-pty`](../packages/pty/tool-pty)、[`tool-skill`](../packages/skill/tool-skill)、[`tool-subagent`](../packages/subagent/tool-subagent)、[`tool-todo`](../packages/todo/tool-todo)、[`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | -| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 入口提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | +| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/interaction/user-interaction) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | @@ -409,7 +409,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge)、[`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop)、[`acp`](../packages/acp/acp)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接前门与 Host 支撑的 Agent 前门共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless)、[`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b)、[`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index fd29baab75..8fb1893fca 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 38483b5c4993a44562970782dca5f676e4cb84f6 -07-into-the-harness.zh.md: 59ce716bdace894682bc1c8e6e00cf174008c26c +07-into-the-harness.md: 69133786f58541b015aed080f4ac8fb2a7e488c0 +07-into-the-harness.zh.md: bc9c61da984e3eb691eb6bfbe59ae556823e82de diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 38483b5c49..69133786f5 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -95,7 +95,7 @@ The logger fired first: `tools/result` is emitted as part of result materializat ## From here to a full agent -A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, a front end. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. +A real agent is this composition plus more plugins: an LLM adapter, the agent loop, persistence, an entry point. Compare [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml) — you can read every entry in it now. Add your `greet-tool.ts` to a copy of that file. Where to go next: diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 59ce716bda..bc9c61da98 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -95,7 +95,7 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 ## 从这里走向完整 agent(智能体) -真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和前端。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 +真实 agent 就是这套组合再加上更多插件:LLM(大语言模型)适配器、agent loop(智能体循环)、持久化和运行入口。对照 [examples/headless-agent/cordis.yml](../../examples/headless-agent/cordis.yml),你现在已经可以读懂其中每个配置项。将 `greet-tool.ts` 加入该文件的副本即可。 后续可以阅读: diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index e540ed7d90..0b2b87a954 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 9a1fa827a7e0168e662bc4595a3c0fc486be8d49 -core.zh.md: e51f8b61fb6ed0c7a6e2de377f8f93877f972831 +core.md: af27484160769156836f377e5b3aba2521280005 +core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 9a1fa827a7..af27484160 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -330,7 +330,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index e51f8b61fb..12935f4d88 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -338,7 +338,7 @@ currentSelection(): ModelSelection /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index f4a719fdb2..c95a0db6c0 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 29575fcc860721d35588d82a15d9b97f1b7dea0e -providers.zh.md: 4b5fd32224fbfe5b685f886375b590fa3704505d +providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 +providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 29575fcc86..4667e54161 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct front doors and Host-backed front doors read that same service. +After a session has run a turn, its own log remains authoritative for its model selection; the default applies only to sessions without a recorded request. The shipped fallback under this section is the base bundle's `agent-default-model` composition entry (`deepseek-official` / `deepseek-v4-flash`). A self-assembled `cordis.yml` mounts and configures `@deepseek-ai/dsh-agent-default-model`; both direct entry points and Host-backed entry points read that same service. If the provider a saved default names is later removed, the composer says **Select model** and refuses input until you pick one, rather than sending to a route nothing serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 4b5fd32224..15e0ccc826 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -133,7 +133,7 @@ agent-default-model: reasoningEffort: high # optional ``` -会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接前门与 Host 支撑的前门都读取同一服务。 +会话跑过一轮后,其自身日志仍是模型选择的权威;默认值只适用于尚无请求记录的会话。这个段落之下的出厂兜底是 base 组合包的 `agent-default-model` 组合条目(`deepseek-official` / `deepseek-v4-flash`)。自行组装的 `cordis.yml` 会挂载并配置 `@deepseek-ai/dsh-agent-default-model`;直接入口与 Host 支撑的入口都读取同一服务。 如果某个已存默认值指向的提供方后来被删掉了,输入框会显示**选择模型**并拒绝输入,而不是把消息发给一个没人服务的路由。 diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 5e6a21e1ec..49614b8c2d 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -24,7 +24,7 @@ flowchart LR cfg --> plugin_acp_acp_agent plugin_acp_acp_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_acp_acp_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_acp_acp_agent --> frontdoor_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] + plugin_acp_acp_agent --> entrypoint_acp["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 07a1dc582a..90dd1c4f2a 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: 6e80e56dec70c2be341ae5dfbad70e13a5109715 +README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 6e80e56dec..f12a56920c 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product front door. +This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product entry point. ## Run it diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index c512127199..977afa8863 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -65,7 +65,7 @@ - id: agent name: '@deepseek-ai/dsh-agent' - # The transport-independent default for Agents created by front doors. + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..e099db50fc 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: d8e88cb7b0215b06cd55a4ee9a7932ef180f572f +README.zh.md: 073a41cac95aeb96b658b075011d8212684f1c65 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..d8e88cb7b0 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. -A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. +A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..073a41cac9 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -4,7 +4,7 @@ skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 -pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..2a2d67744e 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -8,7 +8,7 @@ * determinism * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a * leading `/name` naming a user-invocable skill and injects the rendered - * body for every front end, including `disable-model-invocation` skills the + * body for every entry point, including `disable-model-invocation` skills the * model-side catalog never lists (issue #1470). The RPC rides the plugin's * root-context connection captured at registration — the source never reads * services off a per-call argument. Draft chip visuals derive from @@ -167,7 +167,7 @@ export function apply(ctx: ClientContext): void { // lands plain text and the prompt ships the same // literal. Determinism lives host-side — the host's // pre-step boundary (dsh-tool-skill) recognizes the leading /name and - // injects the rendered body for every front end. A name shared with a + // injects the rendered body for every entry point. A name shared with a // host command still resolves to the command: adjudication claims the // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } diff --git a/packages/core/README.i18n.yaml b/packages/core/README.i18n.yaml index 4d9ab302d8..44e417c057 100644 --- a/packages/core/README.i18n.yaml +++ b/packages/core/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/README.md -README.md: 8349371ab565f2e9e735cd959026936c7ec44081 +README.md: 504686f8563f073fc8261c88275a5cdd172dd060 README.zh.md: e19c446584cfac75cb50833fa01586688b9c7c92 diff --git a/packages/core/README.md b/packages/core/README.md index 8349371ab5..504686f856 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -11,10 +11,10 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, deploy | [`system-prompt/`](system-prompt/README.md) | Prompt and tool-schema assembly registry | `ctx.systemPrompt` | | [`tools/`](tools/README.md) | Scoped tool registry and execution pipeline | `ctx.tools` | | [`agent/`](agent/README.md) | Agent interface, registry, and event vocabulary | `ctx.agents` | -| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent front doors | `ctx.agentDefaultModel` | +| [`agent-default-model/`](agent-default-model/README.md) | Default model selection shared by Agent entry points | `ctx.agentDefaultModel` | | [`agent-loop/`](agent-loop/README.md) | Default concrete agent driver | `ctx.agentLoop` | -`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent front door uses only when a session has no selection of its own. +`scope` supplies the shared scoping primitive. `agent` owns the public contract, while `agent-loop` is its default implementation; extension plugins depend on the seam so the driver remains swappable. `agent-default-model` owns the deployment selection an Agent entry point uses only when a session has no selection of its own. Runnable compositions belong to [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md); this group owns only the swappable spine pieces. diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 92c6095788..7835a159bc 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-default-model/README.md -README.md: 02bcc9be3adee2293a20b3ae87ddaf4d52e70deb +README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 02bcc9be3a..98bc7d082e 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when a front door creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct front doors such as `dsh run` and Host-backed front doors such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. @@ -13,7 +13,7 @@ The service does not validate catalog membership. A provider route may serve an ## Model Experience -Indirectly, through the provider/model selection supplied to a front door; request assembly and adapters own the model-visible request. +Indirectly, through the provider/model selection supplied to an entry point; request assembly and adapters own the model-visible request. #### KV Cache effect @@ -21,5 +21,5 @@ Changing the default affects only Agents that subsequently resolve from it. An e ## Known Limitations and Deferred Work -- The service owns one process-wide default; per-session selection remains the front door's responsibility. +- The service owns one process-wide default; per-session selection remains the entry point's responsibility. - Without a settings provider, `saveSelection()` cannot retain a selection for a later Agent. diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index d012fbea93..0035b0b617 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-default-model", - "description": "Default model selection shared by Agent front doors", + "description": "Default model selection shared by Agent entry points", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 36b3b9ba44..4d09b86eb3 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -92,7 +92,7 @@ export class AgentDefaultModelService extends Service { /** * Save the complete default model selection. A deployment without a settings * provider keeps its composition entry. - * @param next - resolved selection accepted by a front door. + * @param next - resolved selection accepted by an entry point. * @returns fulfillment after the optional settings write settles. */ async saveSelection(next: ModelSelection): Promise<void> { diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index 4d36ca34fb..a49e2f5979 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -1,5 +1,5 @@ /** - * Agent-scoped model selection shared by interactive front doors. + * Agent-scoped model selection shared by runtime entry points. * @module @deepseek-ai/dsh-agent/model-selection */ @@ -33,7 +33,7 @@ export interface ModelSelectionRef { * the selected model's provider/default behavior. * * @param agentCtx - The selected Agent's scoped context. - * @param selection - Mutable selection owned by the calling front door. + * @param selection - Mutable selection owned by the calling entry point. * @returns Disposer for both scoped waterfall listeners. */ export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index e77a9283b2..2270947eea 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0 -README.zh.md: 24f64096dda0ccdac51afb90754ae25950750b89 +README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 +README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 diff --git a/packages/examples/README.md b/packages/examples/README.md index 2d672dcc30..d8369b1e26 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and a front door by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. +Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling the spine and an entry point by hand. These are **demo / reference** packages — the `-demo` npm suffix marks each one as non-product surface, readable straight off the package name. The runnable leaves under the repo-root [`examples/`](../../examples/AGENTS.md) and the [Python SDK runtime](../../python/sdk-runtime/README.md) are the consumers; each is just its swappable backends plus one bundle entry. | Package | npm name | Role | |---|---|---| @@ -10,8 +10,8 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. -These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions. +These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 24f64096dd..acb402e925 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和运行入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 | 包 | npm 名称 | 角色 | |---|---|---| @@ -12,6 +12,6 @@ `agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 -这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包选择具体组合。 +这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 不要将此组与仓库根目录的 [`examples/`](../../examples/AGENTS.md) 混淆:该目录存放可运行的 `cordis.yml` **叶节点**;此组存放这些叶节点加载的 **组合包**。 diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index ff17cf5bb7..af16e2eacd 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md -README.md: 395ab230146568989c4e6d1361218efb72d857e7 -README.zh.md: 667fc1a794eba15c7754ad9887f8083d3643f3e6 +README.md: edc45c9857a631cef72eb41b1a98c390f112291e +README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 395ab23014..edc45c9857 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -55,4 +55,4 @@ Append-only per session; the app adds no request-prefix content itself. - **JSONL persistence is fixed** — a different backend requires another composition. - **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes. -- **Fresh automation sessions only** — resume and human interaction belong to other front doors. +- **Fresh automation sessions only** — resume and human interaction belong to other entry points. diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 667fc1a794..c2946aa3d1 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -55,4 +55,4 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 - **JSONL 持久化固定不变**:使用其他后端需要另一种组合。 - **同级插件可能破坏 stdout**:应用无法阻止另一个条目写入非协议字节。 -- **只支持新建自动化会话**:恢复和人工交互属于其他前端入口。 +- **只支持新建自动化会话**:恢复和人工交互属于其他运行入口。 diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 68a2909836..8a900dd3cd 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -71,7 +71,7 @@ export interface Config { goals?: agentCore.GoalConfig | false } -// Each front door owns a complete, directly readable config schema; extracting +// Each entry point owns a complete, directly readable config schema; extracting // the common fields would make two small app contracts depend on a new facade. /* jscpd:ignore-start */ export const Config: z<Config> = z.object({ @@ -114,7 +114,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> { const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) await spine yield spine.dispose - // Same rationale as the Config schema above: each front door forwards its own + // Same rationale as the Config schema above: each entry point forwards its own // persistence passthroughs rather than sharing a facade with stdio-demo. /* jscpd:ignore-start */ const persistence = ctx.plugin(SessionPersistenceJsonl, { diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a698160634..44de45d88f 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -README.md: cfec2c46ada8ed97aef44fb1d4145ddecdeecab1 -README.zh.md: d482ea9ca7034874383472050a8c837bea5bfaad +README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195 +README.zh.md: e5a8672d494e0c456aa820641e685d00be624445 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index cfec2c46ad..cf0dc2ecd6 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. +The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only an entry point and the swappable backends. Read this package for the whole plugin tree and its composition order. @@ -41,15 +41,15 @@ Read this package for the whole plugin tree and its composition order. ## What it deliberately leaves OUTSIDE the bundle -The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle: +The spine is everything COMMON to every entry point. The swappable and entry-point-coupled pieces stay out, picked by whatever loads the bundle: - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. +- **entry point + per-app infrastructure** — headless, ACP, and JSON-RPC app packages own transport, stdout, and reload choices. `timer` stays in the spine because it is common and stdout-silent. -This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. +This applies the [Service Definition / Service provider / Consumer separation](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) at the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the entry point. ## Config @@ -65,9 +65,9 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/ ## Why a code bundle, not a shared YAML include -A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. +A YAML include can deduplicate config but cannot own a bin or provide entry-point defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. -The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. +The retry policy may repeat a failed request in a new numbered step. Retry status, provider errors, and failed partial chunks stay outside model history; each provider attempt can still incur billing, always mode has no attempt limit, entry points derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse. ## Model Experience diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index d482ea9ca7..e5a8672d49 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -41,15 +41,15 @@ ## 有意留在组合包外的组件 -主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: +主干包含每个入口都共有的全部组件。可替换组件和与入口耦合的组件留在外部,由加载组合包的一方选择: - **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -- **前端入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 +- **入口与各应用基础设施**:无头、ACP(Agent Client Protocol)和 JSON-RPC 应用包负责传输、stdout 与重新加载选择。`timer` 保留在主干中,因为它是共有组件且不写 stdout。 -这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 +这里在组合层应用 [Service Definition/Service provider/Consumer 的职责分离](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):组合包拥有共享主干,叶节点拥有后端,应用包拥有入口。 ## 配置 @@ -65,9 +65,9 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' ## 为何使用代码组合包,而非共享 YAML include -YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 +YAML include 可以去重配置,却无法拥有 bin 或提供入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 -重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 ## 模型体验 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 30c51fc941..7d9e99a908 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -165,7 +165,7 @@ export const Config = z.intersect([ ]) as unknown as z<Config> /** - * Copy the bundle-owned fields from an app config without leaking front-door settings. + * Copy the bundle-owned fields from an app config without leaking entry-point settings. * @param config - App config containing the shared spine fields. * @returns The fields accepted by this bundle, preserving optional absence. */ diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 8ffd971829..8de047a79a 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -236,7 +236,7 @@ describe('dsh-agent-spine-demo bundle', () => { } }) - it('loads and configures bounded request recovery for every bundled front door', async () => { + it('loads and configures bounded request recovery for every bundled entry point', async () => { const adapter = new TransientOnceAdapter() const ctx = await mount({ workspaceContext: false }) ctx.llm.registerAdapter(['mock'], adapter) @@ -704,9 +704,9 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) - it('picks shared spine config without leaking front-door fields', () => { + it('picks shared spine config without leaking entry-point fields', () => { const appConfig = { - model: 'front-door-only', + model: 'entrypoint-only', includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index bc4a93d760..d919320643 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: e2eb6d4cf2b40e83efad1fa158edd72578658f56 +README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index e2eb6d4cf2..d7849e25fc 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -56,4 +56,4 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. -- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. +- **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 30f7a65c51..cb86d86c92 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -143,7 +143,7 @@ export function apply(ctx: Context, config: Config): void { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] // Run the hook in the agent's session workspace (the `session/new` cwd on the session - // header), not the executor or front-door process's launch dir. + // header), not the executor or entry-point process's launch dir. const workdir = opts.agent?.session.header.cwd // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session // workspace (the same dir the hook runs in). diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f449e05a28..cba01a89bf 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 98fcdda155286feb23aaf294cab76529ce31cfc1 -README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c +README.md: c29f30b85c5579f278ac9b40a0422347502eeb8f +README.zh.md: 92b866bafd71902c55bf0bad14c6b9e761421cf8 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 98fcdda155..c29f30b85c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,13 +50,13 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core front door and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8cfa7e527a..92b866bafd 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index bd060e7d19..3e5157ca90 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -8,7 +8,7 @@ * routes — physical carriers wrap `ctx.apiProxy` themselves. * * The gateway consumes `ctx.agentDefaultModel`, the transport-independent default - * shared with direct front doors. Switching models persists through that + * shared with direct entry points. Switching models persists through that * service; sessions that have already logged a selection remain unchanged. */ diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 7d4217d3d4..2a9323474e 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: 6c8ba23b76e83665d4f8dcb5ecb41689347f6423 +README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 6c8ba23b76..c404cfa730 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -18,7 +18,7 @@ The review question declares the `plan-review` presentation intent, naming `Appr When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. -The Web client consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary. +The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary. ## Session projection diff --git a/packages/scaffold/helper/src/features/builtin/app.ts b/packages/scaffold/helper/src/features/builtin/app.ts index 106cf07097..1695accf56 100644 --- a/packages/scaffold/helper/src/features/builtin/app.ts +++ b/packages/scaffold/helper/src/features/builtin/app.ts @@ -50,7 +50,7 @@ class AppOption extends FeatureOption { this.label = label } - /** Identify options by their unique front door, not the shared interaction service. */ + /** Identify external options by their run interface, not the shared interaction service. */ override markerConfigEntries(): readonly { id: string; name: string }[] { switch (this.id) { case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }] @@ -58,7 +58,7 @@ class AppOption extends FeatureOption { } } - /** Embed is identified by the configured loop with no external front door. */ + /** Embed is identified by the configured loop and absence of an external entry point. */ override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean { if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile) return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop') @@ -93,7 +93,7 @@ export class AppFeature extends ExclusiveOptionFeature { new AppOption('embed', 'Embedded context'), ] - /** Default to the profile's already selected front door. */ + /** Default to the profile's already selected run interface. */ override defaultOptions(profile: ProjectProfile): readonly string[] { return [profile.runInterface] } diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 5eb3d2bc5a..93d51c5ffe 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -112,7 +112,7 @@ export abstract class Feature { readonly requires: readonly FeatureId[] = [] /** Features recommended during creation. */ readonly suggests: readonly FeatureId[] = [] - /** Front doors under which this feature is meaningful. */ + /** Run interfaces under which this feature is meaningful. */ readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed'] /** @@ -141,7 +141,7 @@ export abstract class Feature { } /** - * Whether the feature may be selected for this project front door. + * Whether the feature may be selected for this project run interface. * @param profile - project context to check. * @returns whether the feature applies. */ diff --git a/packages/scaffold/helper/src/project/types.ts b/packages/scaffold/helper/src/project/types.ts index 0c66bb5ac2..72f4259e88 100644 --- a/packages/scaffold/helper/src/project/types.ts +++ b/packages/scaffold/helper/src/project/types.ts @@ -8,7 +8,7 @@ import type { PackageManager } from '../package-managers/package-manager.ts' import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts' import type { FeatureId } from '../ids.ts' -/** Runtime front door selected for a generated project. */ +/** Run interface selected for a generated project. */ export type RunInterface = 'acp' | 'embed' /** Values shared by the required provider and app features. */ diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index d0f275ca4c..41acf530ba 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -209,7 +209,7 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks') }) - it('round-trips embed app projects without a front-door Cordis config entry', async () => { + it('round-trips embed app projects without an ACP Cordis config entry', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-')) temporary.push(root) const creation = request([], [], 'embed') diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 0d984e79e1..86def75601 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -72,7 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async saveSelection(next: ModelSelection): Promise<void>', - jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by a front door.\n * @returns fulfillment after the optional settings write settles.\n */', + jsDoc: '/**\n * Save the complete default model selection. A deployment without a settings\n * provider keeps its composition entry.\n * @param next - resolved selection accepted by an entry point.\n * @returns fulfillment after the optional settings write settles.\n */', }, ], }, diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 3c99c7a770..6b3e20a82c 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts' * A write-through console for one sandbox, tagging every line with the mount * id. Write-through (host stdout/stderr), NOT buffered into the tool result: * a mounted listener fires long after the mount call returned, and its output - * must land somewhere the user can see — for a terminal front door, the host terminal. + * must land somewhere the user can see — for a terminal entry point, the host terminal. */ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> { const tag = `[cordis:${id}]` diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 59a8e0200f..26e9233f82 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -258,7 +258,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Human question/answer seam', mode: 'seam', consumers: ['tool-ask-user'], - note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', + note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.', }, { key: 'planMode', @@ -320,7 +320,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Default Agent model selection', mode: 'core', consumers: ['headless', 'host-apiproxy'], - note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.', + note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', }, { key: 'agentLoop', @@ -682,7 +682,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) if (pluginName === '@deepseek-ai/dsh-acp-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) + lines.push(` ${appNode} --> ${nodeId('entrypoint', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`) } lines.push( ` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`, From ec310e60f81599b8b67c28544b047c2aa9c541de Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 13:12:35 +0800 Subject: [PATCH 65/81] test(web): align steer-all snapshots after master sync --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 6 ++++-- apps/web/tests/snapshots/steer-all/settled.expected.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..8b77a77a0c 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -17,10 +19,10 @@ - img - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply." +- text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..0899529a09 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -19,10 +21,10 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img - paragraph: "Got it: BANANA and ORANGE." From 5d86a284e548ccfac0557cd2ea4ff106ac9e1306 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:18:41 +0800 Subject: [PATCH 66/81] fix(web-app,agent-presets): keep the task registry on the host plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tool-bash` resolves the background-task registry with `ctx.get('tasks')`, and it sits at the preset's top level. The registry sat inside an entry-local `isolate: { tasks: true }` realm, which is invisible to every sibling row outside it, while the Web surface disabled the host row — so both lookups missed and every `run_in_background` call answered "background tasks unavailable" with `task_output`, `task_list`, and `task_kill` still listed in the catalog. `task_list` returning "(no background tasks)" is what made the outage read as an empty queue rather than a severed producer. That is the `goals` criterion read from inside the preset: a Service a row outside its realm READS belongs to the plane both can see. `tasks` already keys access by owning agent (`assertAccess` compares `task.owner.id`) and mints an independent token per `attachSurface` call, so one host instance serves every session exactly as before presets — the per-preset-standing-mounts note records that sharing `tasks-local` is a return to its design. `minimal` mounts no `tool-tasks`, and the `start()` control-surface gate is a service-wide set that another preset's controls would open for it, so its `tool-bash` disables `run_in_background` and drops the parameter from the schema. Fixes #2141 --- .../agent-presets/code/agent.cordis.yml | 20 +++--- .../agent-presets/cordis/agent.cordis.yml | 20 +++--- .../agent-presets/minimal/agent.cordis.yml | 8 +++ .../agent-presets/standard/agent.cordis.yml | 20 +++--- apps/web/tests/shipped-composition.e2e.ts | 62 +++++++++++++++++++ packages/bundle/web-app/cordis.patch.yml | 13 +++- 6 files changed, 107 insertions(+), 36 deletions(-) diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..d068e00dea 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -70,17 +70,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..91fa28a31d 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -64,17 +64,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── goals ─────────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 8ca6f0dcdf..cbccafe160 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -22,8 +22,16 @@ # never reached the model's shell at all. `tool-bash` consumes the host registry # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. +# +# `run_in_background` is off because this preset mounts no `tool-tasks`: the +# host task registry gates starts on SOME control surface being attached, and +# that set is process-wide, so another preset's controls would let this agent +# start work it has no `task_output` to collect. Disabling drops the parameter +# from the schema too, which is the honest surface for a two-tool benchmark. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + config: + enableRunInBackground: false - id: tool-str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..f73f4b3fba 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -63,17 +63,15 @@ # ── background tasks ──────────────────────────────────────────────────────── -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' +# Only the model-facing controls. The task REGISTRY stays on the host plane: +# its producers sit outside any realm this file could put it in — `tool-bash` +# above resolves it with `ctx.get`, and an entry-local realm here is invisible +# to every sibling row, so `run_in_background` would answer "background tasks +# unavailable" while these controls sat in the catalog. The registry is keyed by +# owning agent anyway, so one host instance serves every session. What a preset +# chooses is whether its agent can collect and stop background work at all. +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' # ── skills ────────────────────────────────────────────────────────────────── diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 5d929044b3..8241dfc0bc 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -5,6 +5,7 @@ // surface itself. import { tmpdir } from 'node:os' import { afterEach, expect, it } from 'vitest' +import { CallId } from '@deepseek-ai/dsh-llm' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the tools/sandboxPolicy/approval Context merges. @@ -114,3 +115,64 @@ it('assembles the shipped Web catalog with the confined access default', async ( await commandHandle.dispose() } }, 120_000) + +it('lets a preset producer reach the background-task registry', async () => { + scaffold = await launchWebScaffold() + const ctx = scaffold.ctx + const handle = await ctx.agents.create({ + sessionId: SessionId('shipped-background-task'), + meta: { cwd: scaffold.workspaceCwd }, + setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), + }) + try { + const signal = new AbortController().signal + // `tool-bash` is a preset row and `tasks` is a host registry; the producer + // resolves it with `ctx.get`, so a registry hidden behind a preset realm + // fails here — with every task control still listed in the catalog above. + const started = await ctx.tools.execute({ + signal, + callId: CallId('shipped-bash-background'), + name: 'bash', + arguments: { + command: 'printf SHIPPED_BACKGROUND_OK', + description: 'shipped background probe', + run_in_background: true, + }, + agent: handle.agent, + }) + expect({ isError: started.isError, content: started.content }).toEqual({ + isError: false, + content: [{ type: 'text', text: 'started background task bash-1' }], + }) + + // The control surface reads what the producer started: same registry, one + // owner. A per-preset registry would list nothing here even on success. + const listed = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-list'), + name: 'task_list', + arguments: {}, + agent: handle.agent, + }) + expect(listed.isError).toBe(false) + expect(listed.content).toEqual([ + { type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string }, + ]) + + // The full round trip: the output a host-plane producer wrote is collected + // through a preset-plane control, which is the linkage the realm severed. + const collected = await ctx.tools.execute({ + signal, + callId: CallId('shipped-task-output'), + name: 'task_output', + arguments: { task_id: 'bash-1', wait: true }, + agent: handle.agent, + }) + expect(collected.isError).toBe(false) + expect(collected.content).toEqual([ + { type: 'text', text: expect.stringContaining('SHIPPED_BACKGROUND_OK') as unknown as string }, + ]) + } finally { + await handle.dispose() + } +}, 120_000) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..ed08451f1b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -218,10 +218,17 @@ - id: tool-bash disabled: true -- id: tool-tasks - disabled: true +# The background-task REGISTRY stays on the host plane; only the model-facing +# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a +# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it +# with `ctx.get`, and an entry-local realm around the registry is invisible to +# every sibling row outside that realm, so `run_in_background` answered +# "background tasks unavailable" while the controls sat in the catalog. That is +# the `goals` criterion read from inside the preset: a Service a row outside its +# realm READS belongs to the plane both can see. The registry is keyed by owning +# agent, so one host instance serves every session exactly as before presets. -- id: tasks +- id: tool-tasks disabled: true - id: tool-fs From 75100ad2e5e0d081d3f81a22fdd6bd11bf464631 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:22:55 +0800 Subject: [PATCH 67/81] fix(agent-presets): repair Windows preset CI --- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/README.md | 2 ++ packages/preset/agent-presets/README.zh.md | 2 ++ packages/preset/agent-presets/src/mount.ts | 13 +++++--- .../agent-presets/tests/authoring.spec.ts | 17 ++++++----- .../agent-presets/tests/discovery.spec.ts | 30 +++++++++++++++++-- .../preset/agent-presets/tests/mount.spec.ts | 19 +++++++++++- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index cb80d89ad8..65af853308 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: ed640cf053ac595dfb9c20c226f3c2ff34db93f6 -README.zh.md: 4e6fc0a4cf0db4b14b136cbad9f73eee64d9c170 +README.md: b6d469b26a0254adc654e5cc49d3df2d10817b2d +README.zh.md: 60c7bc695c27bf2c0169a0e405aa84aa711b9b21 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index ed640cf053..b6d469b26a 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -55,6 +55,8 @@ A row's **package name** resolves from the host composition, not from the preset A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it. +An **absolute** filesystem path keeps its own location. The mount converts it to a `file:` URL before ESM import so POSIX paths and Windows drive-letter or UNC paths use a specifier Node accepts. + ### Display metadata A preset may publish display text in an optional `preset.yml` beside its composition: diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 4e6fc0a4cf..60c7bc695c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -55,6 +55,8 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有 **相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。 +**绝对**文件系统路径则保留其自身位置。挂载会先将它转换为 `file:` URL 再交给 ESM 导入,从而使 POSIX 路径和 Windows 盘符或 UNC 路径都采用 Node 能够接受的说明符。 + ### 展示用元信息 preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index fac3319fa1..eb890255ca 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -14,6 +14,7 @@ * @module @deepseek-ai/dsh-agent-presets/mount */ +import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' import { Context, type Fiber } from 'cordis' import { Include } from '@cordisjs/plugin-include' @@ -69,21 +70,25 @@ class PresetTree extends Include { * where Node's upward `node_modules` walk never reaches the harness's own * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The * mount records the host composition's base instead, which is inside the - * installed harness, and bare names resolve from there. + * installed harness, and bare names resolve from there. An absolute + * filesystem path names neither base and becomes a file URL before Node's + * ESM loader receives it, which is required for drive-letter paths on + * Windows. * @param name - the module specifier from the row. * @param getOuterStack - the loader's stack composer for import diagnostics. * @returns the imported module, or the `cordis:` builtin. */ override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name const base = harnessBase.get(this.config) /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */ - if (base === undefined) return super.import(name, getOuterStack) + if (base === undefined) return super.import(specifier, getOuterStack) if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack) const internal = this.ctx.loader.internal /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a hypothetical embedder from losing the row's name in a resolution error. */ - if (internal === undefined) return super.import(name, getOuterStack) - return internal.import(name, base, {}) + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, base, {}) } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index b71776c264..b6f4ef388a 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -65,20 +65,23 @@ describe('copying a preset', () => { expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user') }) - it('copies the whole directory, execute bits kept and group/other stripped', async () => { + it('copies the whole directory and tightens POSIX modes', async () => { await seedPreset(userRoot, 'source', { extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' }, }) - await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + if (process.platform !== 'win32') { + await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755) + } await ctx.agentPresets.copy('source', 'mine') expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n') - // A preset may ship runnable helpers; the copy keeps them runnable for the - // owner while withdrawing the world-readability of the install. - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) - expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) - expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + // Windows mode bits are synthetic and cannot represent the inherited DACL. + if (process.platform !== 'win32') { + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700) + expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600) + expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700) + } }) it('keeps the source description but never its name or order', async () => { diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 55845c2251..165a4cd17f 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -1,14 +1,37 @@ -import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' +const fsHarness = vi.hoisted(() => ({ + nextReadError: undefined as NodeJS.ErrnoException | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs/promises')>() + return { + ...actual, + readFile: (async (path: unknown, ...rest: never[]) => { + const error = fsHarness.nextReadError + if (error !== undefined) { + fsHarness.nextReadError = undefined + throw error + } + return (actual.readFile as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest) + }) as typeof actual.readFile, + } +}) + const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } +beforeEach(() => { + fsHarness.nextReadError = undefined +}) + describe('display order', () => { it('puts declared order first, then everything else by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) @@ -177,10 +200,11 @@ describe('composition health', () => { await mkdir(join(root, 'sealed')) const path = join(root, 'sealed', COMPOSITION_FILE) await writeFile(path, '[]\n') - await chmod(path, 0o000) + fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' }) const [preset] = await scanRoot({ path: root, trust: 'user' }) + expect(fsHarness.nextReadError).toBeUndefined() expect(preset?.broken).toMatch(/cannot be read/) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index b4988331cc..c84dae525b 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -11,7 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' @@ -84,6 +84,23 @@ beforeEach(async () => { }) describe('composing an agent from a preset', () => { + it('hands an absolute plugin path to Node as a file URL', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) + const presetDir = join(root, 'absolute') + const plugin = join(FIXTURES, 'plugins', 'contribute.js') + await mkdir(presetDir) + await writeFile( + join(presetDir, COMPOSITION_FILE), + `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, + ) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const imported = vi.spyOn(scoped.loader.internal!, 'import') + + await agentOn(scoped, 'sess-absolute-plugin') + + expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {}) + }) + it('gives each session only its own preset\'s tools', async () => { const alpha = await agentOn(ctx, 'sess-alpha', 'standard') const beta = await agentOn(ctx, 'sess-beta', 'minimal') From 52fafa012e8616b5587863b0e2fe05221255a982 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:30:02 +0800 Subject: [PATCH 68/81] test(agent-presets): stabilize generation race coverage --- .../preset/agent-presets/tests/mount.spec.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index c84dae525b..77c549a689 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -542,6 +542,34 @@ describe('editing a composition file', () => { expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2) }) + it('keeps a newer generation pointer when a stale refresh loses the swap race', async () => { + const { scoped, path } = await editable('guarded-refresh') + const preset = await scoped.agentPresets.resolve('guarded-refresh') + await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh') + const service = scoped.agentPresets as unknown as { + standing: Map<string, Promise<{ + key: unknown + scope: unknown + stamp: { mtimeMs: number; size: number } + }>> + ensureStanding(current: typeof preset): Promise<unknown> + } + const stalePromise = service.standing.get(preset.id)! + const stale = await stalePromise + await writeFile(path, rowFor('afterwards')) + const { mtimeMs, size } = await stat(path) + const newer = { ...stale, stamp: { mtimeMs, size } } + const newerPromise = Promise.resolve(newer) + + // `await pending` yields before the guarded delete, letting the winning + // refresher replace the pointer deterministically instead of by timing. + const refresh = service.ensureStanding(preset) + service.standing.set(preset.id, newerPromise) + + expect(await refresh).toBe(newer) + expect(service.standing.get(preset.id)).toBe(newerPromise) + }) + it('hands a host reader the standing key without starting an agent', async () => { const { scoped } = await editable('cold-read') From 259d998455d625679549f8941a1ddba9a6ec5516 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:35:13 +0800 Subject: [PATCH 69/81] fix(web): follow a blank session's preset switch in the slash catalog Presets own the rows that decide what a session's `/` menu contains, but both browser catalogs cache per session and had no invalidation edge for a recompose: `commands/changed` is registry-wide and recomposing registers nothing, so the menu kept serving the composition the session no longer ran. The host stream now frames the logged `agent-preset/selected` commit as `host/session-preset-changed`; the runtime bridges it to the typed `session/preset-changed` event, `ui-command` soft-refreshes that session's directory key and `ui-skill` invalidates its catalog entry. Reaching the host on a second switch was a separate defect: the list-row identity guard compared every summary field except `agentPreset`, and the merge keeps the row's `updatedAt`, so a switched row looked unchanged and served its cached instance forever. The hero chip compares the pick against that row, so switching back to the creation-time preset sent no RPC at all. --- ...n-row-identity-covers-the-preset.i18n.yaml | 6 + ...-session-row-identity-covers-the-preset.md | 37 ++++++ ...ssion-row-identity-covers-the-preset.zh.md | 37 ++++++ ...sh-catalog-follows-preset-switch.i18n.yaml | 6 + ...-10-slash-catalog-follows-preset-switch.md | 41 +++++++ ...-slash-catalog-follows-preset-switch.zh.md | 41 +++++++ apps/web/tests/agent-preset-selection.e2e.ts | 105 +++++++++++++++--- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 15 +++ .../runtime/src/client/sessions/manager.ts | 2 +- .../runtime/tests/sessions-service.spec.ts | 17 +++ .../client/runtime/tests/wire-events.spec.ts | 14 ++- packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 +- packages/client/ui-command/README.zh.md | 2 +- .../client/ui-command/src/client/service.ts | 5 + .../client/ui-command/tests/service.spec.ts | 24 ++++ packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/src/client/index.ts | 7 +- .../ui-skill/tests/browser-plugin.spec.ts | 15 +++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 11 ++ .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 11 ++ .../tests/api-proxy-agent-preset.spec.ts | 32 ++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 1 + scripts/gen-cordis-catalog.ts | 1 + 36 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml new file mode 100644 index 0000000000..aaa06cf4fe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md +2026-08-10-session-row-identity-covers-the-preset.md: 7a89dcb4e4ae292a06a1743842d2e9cf6bd96282 +2026-08-10-session-row-identity-covers-the-preset.zh.md: 7ffa3423818bcc867c942651540db1975737e073 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md new file mode 100644 index 0000000000..7a89dcb4e4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md @@ -0,0 +1,37 @@ +# Agent Note: The session-row identity guard covers the preset + +Status: implemented + +English | [中文](2026-08-10-session-row-identity-covers-the-preset.zh.md) + +## Problem + +`SessionManager.buildListSnapshot` memoizes list rows by value: a wire refresh mints all-new summary objects, so an entry equal to the cached one is replaced by the cached instance, and every `SessionListItem` memo downstream keeps hitting. The stated contract is "reuse the cached object when every field matches"; the comparison enumerated the fields by hand and did not enumerate `agentPreset`. + +A confirmed preset switch moves exactly that one field. `noteAgentPreset` upserts it and `applyMutation` merges it in — the merge deliberately does not take the mutation's `updatedAt`, so a switched row differs from its cached twin in the preset and in nothing else. The guard therefore judged the row unchanged and served the stale instance, permanently: the manager's own summaries said `minimal` while every reader of the projected snapshot went on reading `standard`. + +The hero chip is one of those readers, and it compares the pick against that row before sending anything. Switching back to the preset the session was created under looked to it like "already on that preset", so it dropped the stage and sent no RPC at all — the chip label moved while the composition did not. A session could be switched away from its creation-time preset once and never back. + +## Decision + +The identity guard compares `agentPreset` alongside the other summary fields, which is what "every field matches" already claimed. Nothing else changes: the memoization, the merge, and the chip's no-op check all stay as they are, because each is correct once the row it reads is. + +## Alternatives considered + +**Have the chip re-read the host instead of the list row.** It would route around the stale row, but the row is also what the session header labels itself from, so the staleness would survive in the surface where it is most visible — and any future reader of `SessionSummary.agentPreset` would inherit the same trap. + +**Drop the entry-identity memoization and rebuild rows every snapshot.** It removes the whole class of missing-field bugs, at the cost the memo exists to avoid: a wire refresh mints new objects for every row, so each refresh would re-render the entire session list. + +**Compare summaries structurally rather than field by field.** A generic deep comparison cannot be added blind: the row carries `projectionValues`, whose reference identity is the deliberate signal that the projection store republished, and folding it into a value comparison would either re-render on every projection tick or mask a real one. + +## Consequences + +Every field a session row carries now participates in row identity, so a surface reading `SessionSummary.agentPreset` sees a switch as soon as the host confirms it — the header label included. The guard is still a hand-written enumeration, so a field added to `SessionSummary` later must be added here too; the `sessions-service` projection test names the failure mode for the next such field rather than only pinning this one. + +## Testing + +`sessions-service.spec.ts` feeds a blank row, notes a switch, and asserts the projected snapshot reports the new preset — it fails on the old guard because the row differs in nothing else. The `agent-preset-selection` web e2e switches down and back up, asserting the host honors the second switch and the `/` catalog returns with it; without this fix the second switch never reaches the host at all. + +## Related + +The same e2e covers [the catalog-invalidation fix](2026-08-10-slash-catalog-follows-preset-switch.md), which is what makes the menu follow either switch once the switch itself lands. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md new file mode 100644 index 0000000000..7ffa342381 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note:会话行的标识判定纳入 preset + +Status: implemented + +[English](2026-08-10-session-row-identity-covers-the-preset.md) | 中文 + +## Problem + +`SessionManager.buildListSnapshot` 按值对列表行做记忆化:一次 wire 刷新会铸造全新的 summary 对象,因此与缓存项相等的行会被替换为缓存实例,下游每一个 `SessionListItem` memo 才能持续命中。它声明的约定是「每个字段都相同就复用缓存对象」,而那段比较是手写枚举字段的,其中没有 `agentPreset`。 + +一次已确认的 preset 切换恰好只移动这一个字段。`noteAgentPreset` 把它 upsert 进去,`applyMutation` 合并它——该合并有意不采用 mutation 的 `updatedAt`,因此切换后的行与它的缓存孪生只在 preset 上不同,别处一致。于是标识判定认为这一行没变,永久地提供了过期实例:manager 自己的 summaries 是 `minimal`,而所有读取投影快照的一方继续读到 `standard`。 + +hero 上的 chip 正是其中一个读取方,而且它在发出任何请求之前会拿这次选择和那一行比较。切回会话创建时的那个 preset,在它看来就是「已经是这个 preset 了」,于是丢弃 stage、根本不发 RPC——chip 的标签变了,组成没变。一个会话可以从创建时的 preset 切走一次,然后再也切不回来。 + +## Decision + +标识判定把 `agentPreset` 与其余 summary 字段一起比较,这本就是「每个字段都相同」所声称的内容。其他一概不动:记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。 + +## Alternatives considered + +**让 chip 改为直接读宿主,而不是读列表行。** 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 `SessionSummary.agentPreset` 的读取方都会继承同一个陷阱。 + +**去掉行标识记忆化,每次快照都重建行。** 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。 + +**改成结构化比较,而不是逐字段枚举。** 通用的深比较不能盲目加:行上带有 `projectionValues`,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。 + +## Consequences + +会话行携带的每个字段现在都参与行标识,因此读取 `SessionSummary.agentPreset` 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 `SessionSummary` 新增字段时必须同步加进来;`sessions-service` 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。 + +## Testing + +`sessions-service.spec.ts` 喂入一行空会话、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败,因为这一行别处都没变。`agent-preset-selection` web e2e 先向下切再向上切,断言宿主认可第二次切换、`/` 目录随之回来;没有这次修复,第二次切换根本到不了宿主。 + +## Related + +同一条 e2e 也覆盖[目录失效的修复](2026-08-10-slash-catalog-follows-preset-switch.md)——正是它让菜单在切换真正落地之后跟随任一方向的切换。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml new file mode 100644 index 0000000000..38cd8786b5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md +2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a +2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md new file mode 100644 index 0000000000..4f32347e04 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -0,0 +1,41 @@ +# Agent Note: The slash catalog follows a blank session's preset switch + +Status: implemented + +English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md) + +## Problem + +Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment. + +Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it. + +The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it. + +## Decision + +The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). + +The frame is per session and carries no catalog. Deriving it from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. + +## Alternatives considered + +**Invalidate in the client's own `agentPresets.select` callback.** Smallest change, and the preset is locked after the first turn, so the hero chip is the only place a switch can originate. Rejected because the invalidation would then live in the surface that happens to issue the RPC rather than at the commit point: a second tab on the same blank session keeps a stale menu, and any future host-side recomposition has no signal at all. + +**Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant. + +**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog. + +## Consequences + +The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones. + +## Testing + +`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition. + +That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id. + +## Related + +Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md new file mode 100644 index 0000000000..fb30df74a9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -0,0 +1,41 @@ +# Agent Note:斜杠目录跟随空会话的 preset 切换 + +Status: implemented + +[English](2026-08-10-slash-catalog-follows-preset-switch.md) | 中文 + +## Problem + +preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local`、`tool-skill`、`plan-mode` 和 `command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。 + +浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。 + +于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact`、`plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。 + +## Decision + +这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 + +该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 + +## Alternatives considered + +**在客户端自己的 `agentPresets.select` 回调里就地失效。** 改动最小,而且第一轮之后 preset 就锁定,hero 上的 chip 是切换唯一可能的发起处。否决理由是失效逻辑会落在恰好发起 RPC 的那个界面上,而不是提交点:同一个空会话在第二个标签页里仍是过期菜单,将来任何宿主侧的重组也完全没有信号。 + +**从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端,不需要新增协议类型。因面(face)分离而否决:把 `event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。 + +**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。 + +## Consequences + +协议多了一个帧,Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。 + +## Testing + +`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset;`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command` 与 `ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。 + +同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。 + +## Related + +第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 69672f49e1..71e1a28b05 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -11,6 +11,7 @@ // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. import { fileURLToPath } from 'node:url' +import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -29,6 +30,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md') const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'agent-preset-selection-web-e2e' +/** A project skill only a preset that mounts `skill-local` can discover. */ +const SKILL_NAME = 'preset-catalog-demo' + +/** + * Seed one project skill under the connected workspace. + * + * Local skill discovery is a PRESET row, so this file is visible through + * `standard` and invisible through `minimal` — which makes the '/' menu's + * skill group a statement about the session's composition. + * @param workspaceCwd - the scaffold's temp project parent. + */ +async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove the slash catalog follows the session composition', + '---', + '', + 'Body.', + '', + ].join('\n')) +} /** * A settled one-turn session with no model content: this lane asserts chrome @@ -53,6 +78,35 @@ function seedLog(): string { ].join('\n') } +/** + * The preset the host reports for the blank session the workspace connect + * produced. Addressed by id rather than by scanning the serialized list: the + * seeded session records `minimal` too, so a substring match over the whole + * list answers before the switch has landed. + * @param baseUrl - the scaffold's origin. + * @returns the live session's preset, or undefined before it is listed. + */ +async function livePreset(baseUrl: string): Promise<string | undefined> { + const response = await fetch(`${baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { items: { sessionId: string; agentPreset?: string }[] } } + } + return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset +} + +/** Every option label the trigger menu currently lists. */ +async function menuOptions(page: Page): Promise<string[]> { + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.waitFor({ timeout: 10_000 }) + return await menu.getByRole('option').allTextContents() +} + describe('web e2e: agent-preset selection', () => { let scaffold: WebScaffold let browser: Browser @@ -67,6 +121,7 @@ describe('web e2e: agent-preset selection', () => { // records `minimal` is what makes the header label a claim about the // session rather than an echo of the current default. await seedSession(scaffold, seedLog(), SEED_ID, 'minimal') + await seedWorkspaceSkill(scaffold.workspaceCwd) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -114,21 +169,45 @@ describe('web e2e: agent-preset selection', () => { // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. - await expect.poll(async () => { - const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {}, - }), - }) - const body = await response.json() as { - result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } } - } - return JSON.stringify(body.result.value?.sessions ?? body.result) - }, { timeout: 15_000 }).toContain('minimal') + await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal') }) + it('re-reads the slash catalog through the composition the switch installed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) + const composer = page.locator('textarea:enabled').last() + + // `minimal` (applied above) mounts neither the compaction group nor plan + // mode nor local skill discovery, so the catalog the composer warmed + // under the deployment default must not survive the switch. + await composer.fill('/') + await expect.poll(() => menuOptions(page), { timeout: 15_000 }) + .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) + const onMinimal = await menuOptions(page) + expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) + expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) + // The host-plane commands and the client's own contribution are the + // floor: they belong to no preset and never move. + expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true) + expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) + await composer.fill('') + + // Switching back up reaches the host at all — the chip compares the pick + // against its list row, so a row that never reprojected the first switch + // answers "already standard" and sends nothing — and restores the catalog + // instead of leaving the session reading the narrower composition. + await page.getByRole('button', { name: '极简模式' }).click() + await page.getByRole('menuitem', { name: /^标准模式/ }).first().click() + await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard') + + await composer.fill('/') + await expect.poll(() => menuOptions(page), { timeout: 15_000 }) + .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) + const onStandard = await menuOptions(page) + expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) + expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) + await composer.fill('') + }, 90_000) + it('labels a resumed session with the preset it was created under', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) // The seeded session's cwd is the scaffold root rather than the connected diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 20fa4fa5e5..9b71f640ed 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 3b8a6b1dd155fd1350b164f1dd2d2bf0ec26a4a5 -event-producer-consumer.zh.md: 12de167fcd1217f00a8ae719ef3191a4873a2799 +event-producer-consumer.md: 70de749c328f1d901ff6f9bc0d97cd52a6f3bf63 +event-producer-consumer.zh.md: 6c49c33a1b1197a7da9bccfc161b7cfa6b6a548f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3b8a6b1dd1..70de749c32 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -70,6 +70,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | +| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 12de167fcd..6c49c33a1b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -72,6 +72,7 @@ | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | +| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index a5d2c2a44f..29d5a2d4dc 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 0a7d9975093da558af623ee9940f4be398526821 -README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a +README.md: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688 +README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 0a7d997509..b1c0c8e5b6 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 41388e4ba5..b6add06324 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 766656a7f9..955431d509 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -181,6 +181,18 @@ declare module 'cordis' { * @mode emit */ 'models/changed'(): void + /** + * One session's agent preset changed (host/session-preset-changed + * passthrough), so everything its composition decides — the command + * catalog, the skill catalog — is stale for that session and no other. + * Every connected client observes it, not only the one that issued the + * switch. Subscribers refetch their own session-keyed caches; the frame + * carries no catalog. + * @mode emit + * @param sessionId - the session whose composition changed. + * @param agentPreset - the preset it now runs. + */ + 'session/preset-changed'(sessionId: SessionId, agentPreset: string): void /** * A connection generation was (re-)established. Wire-derived caches must * treat their state as stale and repull (commands directory; the queue @@ -244,6 +256,9 @@ export function apply(ctx: Context): void { // and model surfaces) subscribe on ctx. const frame = envelope.payload if (frame.type === 'host/commands-changed') ctx.emit('commands/changed') + else if (frame.type === 'host/session-preset-changed') { + ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset) + } else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns) else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref) else if (frame.type === 'host/models-changed') ctx.emit('models/changed') diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ab25781353..ce61351cca 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -1005,7 +1005,7 @@ export class SessionManager { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running - && prev.blank === entry.blank + && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.pendingInteraction === entry.pendingInteraction diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 0a588e8329..e7d702f40a 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -35,6 +35,7 @@ type FeedRow = { origin?: 'subagent' running?: boolean blank?: boolean + agentPreset?: string } async function feedList(b: Bench, rows: FeedRow[]): Promise<void> { @@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> { ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), ...(r.origin !== undefined ? { origin: r.origin } : {}), + ...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}), })), }) as never) await b.svc.refresh() @@ -70,6 +72,21 @@ describe('list store projection', () => { expect(state.byId[sid('s2')]?.title).toBeUndefined() }) + it('reprojects a blank session whose composition switched and nothing else moved', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard') + + // A confirmed switch moves the preset alone: the row keeps its updatedAt, + // title, running, and blank bits, so an identity guard blind to the preset + // would serve the old row forever — and every reader (the hero chip's own + // no-op check, the header label) would keep the composition it replaced. + b.svc.noteAgentPreset(sid('s1'), 'minimal') + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + }) + it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 5b71588732..e82c4cae3b 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -1,6 +1,7 @@ /** * Wire-to-typed-event bridge: host/commands-changed - * → ctx 'commands/changed'; each established connection generation → + * → ctx 'commands/changed'; host/session-preset-changed → + * ctx 'session/preset-changed'; each established connection generation → * ctx 'connection/reset' (the forced cache-invalidation broadcast). */ import { Context } from 'cordis' @@ -67,6 +68,17 @@ describe('wire event bridge', () => { ]) }) + it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => { + const bench = await mount() + const seen: Array<[string, string]> = [] + bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) }) + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r1' as never, + payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' }, + }) + expect(seen).toEqual([['s1', 'minimal']]) + }) + it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { const bench = await mount() let resets = 0 diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index e59b4b2a12..acf611bbca 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-command/README.md -README.md: bc7386c8fca3b5c623473328bee6322fa7295277 -README.zh.md: 54190ac9144b1bfc12ba84a47474311d5a5391ea +README.md: db785e769cb40235a77d05b4b66d096896a35d8a +README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index bc7386c8fc..db785e769c 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach `src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. -`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 54190ac914..f0f23319a8 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -6,7 +6,7 @@ `src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 -`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 33b42f6b51..f17f3950d2 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -124,6 +124,11 @@ export class CommandService extends Service implements CommandServiceContract { warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.on('commands/changed', () => { this.directory.invalidateAll() }) + // A preset switch changes which commands ONE session's agent resolves and + // registers nothing globally, so the registry-wide signal above never + // fires for it: repull that key alone, soft, so the old snapshot serves + // the menu until the new one lands. + ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) }) ctx.on('connection/reset', () => { this.directory.resetConnected() }) } diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index bd6d72c916..f7ee172b8e 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -617,6 +617,30 @@ describe('directory invalidation events', () => { expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() }) + it('session/preset-changed repulls the recomposed session and leaves the others served', async () => { + const rounds = new Map<SessionId, number>() + const { ctx, source, warm } = await bench({ + commands: (payload) => { + const round = (rounds.get(payload.sessionId) ?? 0) + 1 + rounds.set(payload.sessionId, round) + return Promise.resolve({ + commands: round === 1 + ? S1_CMDS + : [{ name: 'fresh', description: '', input: { hint: 'h' } }], + }) + }, + }) + await warm(proj('s1')) + await warm(proj('s2')) + // A preset switch changes which commands one session's agent resolves; + // every other session keeps the catalog its own composition serves. + ctx.emit('session/preset-changed', sid('s1'), 'minimal') + await new Promise(resolve => setTimeout(resolve, 0)) + expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() + expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined() + }) + it('connection/reset hard-drops every session key until its rewarm lands', async () => { let block = false let release!: (value: { commands: CommandDescriptor[] }) => void diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index c9d9e0b69c..475c844c48 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md -README.md: 677ac215d299fca695a6b27c564779ef1d3fd6ee -README.zh.md: 8f1f69b26a932aaa300bdda1d4ec7b2fa749fe3c +README.md: f6bf71bab5c5335d3da073101bcbafb30d1c2757 +README.zh.md: eae61780df7ccee350956dc542ebda70c671feb3 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 677ac215d2..f6bf71bab5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`. A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 8f1f69b26a..eae61780df 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 524cd180cc..8e805f469a 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -17,7 +17,9 @@ * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled * snapshot locally, so one session costs one RPC. The scope-birth warm hook - * prewarms the session's key; connection/reset clears everything — the host + * prewarms the session's key; a preset switch drops that one key (the + * catalog is the preset's, and a blank session may switch after the warm); + * connection/reset clears everything — the host * catalog may differ across generations. A shared in-flight fetch * deliberately outlives any single menu interaction: closing the menu must * not kill the prewarm other consumers will hit, so it carries its own @@ -174,6 +176,9 @@ export function apply(ctx: ClientContext): void { }, } const slash = ctx.get('slash') as SlashServiceContract + // A preset decides which skill providers an agent reads, so a switched + // session's cached catalog belongs to the composition it no longer runs. + ctx.on('session/preset-changed', invalidate) ctx.on('connection/reset', clearAll) ctx.effect(() => { const unregister = slash.registerSource(source) diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index f33924e977..5e143c0b96 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -263,6 +263,21 @@ describe('catalog cache', () => { expect(payloads).toHaveLength(2) }) + it('session/preset-changed clears only the recomposed session', async () => { + const { list, payloads } = countingList() + const { ctx, source } = await bench(list) + await source.candidates(proj('s1'), req('')) + await source.candidates(proj('s2'), req('')) + expect(payloads).toHaveLength(2) + // The catalog a preset supplies is the preset's; the other session's + // composition did not change, so its cached catalog still holds. + ctx.emit('session/preset-changed', sid('s1'), 'minimal') + await source.candidates(proj('s1'), req('')) + await source.candidates(proj('s2'), req('')) + expect(payloads).toHaveLength(3) + expect(payloads[2]).toEqual({ sessionId: 's1' }) + }) + it('connection/reset clears every cached session', async () => { const { list, payloads } = countingList() const { ctx, source } = await bench(list) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f449e05a28..db0e030966 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 98fcdda155286feb23aaf294cab76529ce31cfc1 -README.zh.md: 8cfa7e527a327d9c342a5cf6cf28163f5b45df1c +README.md: 54ac412ca384690a6370c7ee50c54a89972e41b5 +README.zh.md: 94d3caa88b3813a1cc764f69a427ae31257e4e48 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 98fcdda155..54ac412ca3 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,7 +50,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `<skill_content>` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 8cfa7e527a..94d3caa88b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `<skill_content>` 上下文作答,因此每一种前端(web、TUI、ACP(Agent Client Protocol)、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 1fbcfadd2d..70ad99ee89 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3164,6 +3164,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('commands/change', () => { queue.push(frame({ type: 'host/commands-changed' })) }), + // The recompose itself registers nothing (it re-parents the agent's + // scope onto a standing mount that may already exist), so the + // logged selection is the only commit point a client can follow. + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type !== 'agent-preset/selected') return + queue.push(frame({ + type: 'host/session-preset-changed', + sessionId: session.id, + agentPreset: event.data.agentPreset, + })) + }), ctx.on('settings/document-updated', (ns) => { // The RAW-section event, not the resolved one: a field going from // inherited to overridden leaves the resolved value equal, and a diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index b432880810..fc841f9edb 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), + z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), z.object({ type: z.literal('host/models-changed') }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index bbf895625f..43607816b3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -130,6 +130,17 @@ export type HostFrame = * background rather than diffing. */ | { type: 'host/commands-changed' } + /** + * One blank session was recomposed onto another agent preset (the logged + * `agent-preset/selected` commit point, read off the session stream). The + * registry-wide `host/commands-changed` cannot stand in for it: recomposing + * re-parents that agent's scope without registering anything, so a + * preset already mounted for another session produces no registry change + * at all. Clients refetch the catalogs this session's composition decides + * (`command.list`, `skill.list`) for this sessionId alone; the preset id + * rides along for surfaces that label the session. + */ + | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } /** * One settings namespace's resolved value changed (`settings/updated` * passthrough) — an RPC write, an external `settings.yaml` edit, or a diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 24f08bae21..444cd5490e 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { RpcId, type RpcRequest } from '../src/api/rpc.ts' +import type { HostFrame } from '../src/api/events.ts' import { InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' @@ -350,6 +351,37 @@ describe('agentPreset.select', () => { .toBe('core-web') }) + it('frames the committed switch so clients can drop that session\'s catalogs', async () => { + const { api, ctx } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) + // The host-stream opener reads the committed-workspace baseline; this + // spec owns preset identity, so the stub suffices (api-proxy-commands + // precedent). + ctx.provide('workspace', { list: () => [] } as never) + const abort = new AbortController() + const frames: HostFrame[] = [] + const stream = api.events.host(request({}), abort.signal) + const consume = (async () => { + for await (const frame of stream) { + if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload) + } + })() + + await api.agentPresets.select( + request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' })) + // The queue push rides the synchronous append, so one turn of the loop is + // enough to deliver it; closing the stream bounds the read either way. + await new Promise(resolve => setTimeout(resolve, 0)) + abort.abort() + await consume + + // Recomposing registers nothing, so this frame — not the registry-wide + // commands one — is what tells a client its cached catalogs are stale. + expect(frames).toEqual([ + { type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' }, + ]) + }) + it('serializes two concurrent selects on one session', async () => { const { api, ctx } = await harness(['standard', 'core-web']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9824a637bf..f398305fa7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -493,6 +493,7 @@ describe('events frame schemas', () => { } }, { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/commands-changed' }, + { type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 745f44ed0a..63cf4f43ae 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -180,6 +180,7 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = { 'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface', 'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', + 'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the surface', 'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface', From 6a830f1779657b901e017984273a354a2ecd4aeb Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 14:44:37 +0800 Subject: [PATCH 70/81] test(web): stabilize preset-aware steering snapshots --- apps/web/tests/snapshots/steer-all/mid-steer.expected.md | 2 ++ apps/web/tests/snapshots/steer-all/settled.expected.md | 2 ++ apps/web/tests/steering.e2e.ts | 1 + 3 files changed, 5 insertions(+) diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 998ee98129..222097e0a3 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index a61f57572e..d9f1893344 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 8a09582b56..a527081fbe 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -314,6 +314,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.getByText('标准模式', { exact: true }).waitFor({ timeout: 10_000 }) }, 120_000) afterAll(async () => { From e56c6234d2f4cdaa517f875fbddbc3b7c36181d3 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 14:49:30 +0800 Subject: [PATCH 71/81] chore: exclude archived Agent Notes from rg --- .../process/2026-07-26-frozen-agent-note-archive.i18n.yaml | 4 ++-- .../process/2026-07-26-frozen-agent-note-archive.md | 6 +++++- .../process/2026-07-26-frozen-agent-note-archive.zh.md | 6 +++++- .rgignore | 2 ++ 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .rgignore diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 1ae66cc1b7..df1a615d35 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 52b43088b276c0c8e263fc8a81a2df1408cc8059 -2026-07-26-frozen-agent-note-archive.zh.md: a37e06e7cbc19b5000d4849cc3ee2ddc9b451ee3 +2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 +2026-07-26-frozen-agent-note-archive.zh.md: e67f981e5d7ae9d800b725e5ecbaf15dd7fef46b diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 52b43088b2..0c139c4a5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -14,6 +14,8 @@ Only implemented Agent Notes can be archived. An implemented note moves when its The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. + After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources. [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history. @@ -28,6 +30,8 @@ Supersession is checked while a new Agent Note is being written, not deferred to **Keep every implemented and rejected note active.** Rejected because maintenance effort and search noise grow with records that no longer help a future decision. Rejected notes in particular earn retention only by preventing a plausible fallacy. +**Leave archived notes in default repository search results.** Rejected because archived facts may be stale by design and can outrank current results by lexical match. Historical work can search the archive directory explicitly. + **Defer supersession cleanup to periodic corpus audits.** Rejected because the author of a replacement note has the freshest evidence about ownership and overlap. Postponement leaves redundant active authorities and makes later classification more expensive. **Archive rejected or proposed notes too.** Rejected because archive status means “implemented historical decision.” An obsolete proposal needs an explicit rejection, while a rejection with no guardrail value needs deletion rather than a second low-value holding area. @@ -38,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains searchable and linkable without consuming maintenance attention. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index a37e06e7cb..e67f981e5d 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -14,6 +14,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 + 归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。 [`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单) 中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。 @@ -28,6 +30,8 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 **继续将每一份 implemented 和 rejected Agent Note 作为活跃记录保留。** 不予采纳,因为不再帮助未来决策的记录会不断增加维护成本和搜索噪声。尤其是 rejected Agent Note,只有能避免一种可能发生的谬误时,才值得保留。 +**让归档 Agent Note 继续出现在默认的仓库搜索结果中。** 不予采纳,因为归档事实按设计可能已经陈旧,并可能仅凭字面匹配就排在当前结果之前。需要查找历史内容时,可以显式搜索归档目录。 + **把取代关系清理留到定期审计记录集合时再做。** 不予采纳,因为替代记录的作者掌握着关于归属和重叠的最新证据。推迟处理会留下冗余的活跃权威依据,并增加日后分类的成本。 **同时归档 rejected 或 proposed Agent Note。** 不予采纳,因为归档状态表达的是「已经实施的历史决策」。过时的提案需要明确转为 rejected;无法提供防错价值的 rejected Agent Note 则应删除,而不是再放入第二个低价值存放区。 @@ -38,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可搜索和链接,却不再消耗维护精力。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent 必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.rgignore b/.rgignore new file mode 100644 index 0000000000..6bffdc1726 --- /dev/null +++ b/.rgignore @@ -0,0 +1,2 @@ +# Frozen Agent Notes are historical snapshots, not current search authority. +/.agents/notes/archived/ From 0be9bf312ad3bdbeaaf3ab21a8399a20bfb02b73 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 15:12:07 +0800 Subject: [PATCH 72/81] fix(web): fold the preset frame into the session row for every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame carried `agentPreset` for surfaces that label the session, but nothing consumed it: `noteAgentPreset` ran only in the switching tab's RPC callback, so a second connected client refetched its catalogs while its session row — the header label's source, and the hero chip's no-op input — kept the composition the session had replaced. `SessionManager.handleHostEnvelope` now folds the frame like the other session frames. Re-applying the switching tab's own frame is a no-op: the merge lowers `blank` only and keeps the row's `updatedAt`. --- ...lash-catalog-follows-preset-switch.i18n.yaml | 4 ++-- ...08-10-slash-catalog-follows-preset-switch.md | 6 ++++-- ...10-slash-catalog-follows-preset-switch.zh.md | 6 ++++-- apps/web/tests/agent-preset-selection.e2e.ts | 8 +++++--- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/sessions/manager.ts | 8 ++++++++ .../runtime/tests/sessions-service.spec.ts | 17 +++++++++++++++++ .../client/ui-command/src/client/service.ts | 2 +- packages/host/apiproxy/src/api/events.ts | 5 +++-- 11 files changed, 48 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml index 38cd8786b5..55fc08bfb9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md -2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a -2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9 +2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5 +2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md index 4f32347e04..85bd5b2134 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -16,7 +16,9 @@ The menu therefore kept serving the composition the session no longer ran. Switc The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). -The frame is per session and carries no catalog. Deriving it from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. +The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against). + +Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. ## Alternatives considered @@ -38,4 +40,4 @@ That e2e also stopped reading its staged-pick assertion off the serialized sessi ## Related -Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. +Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, `agent-preset-selection.e2e.ts` could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md index fb30df74a9..97c8f08a7b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -16,7 +16,9 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 -该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 +该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。 + +从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 ## Alternatives considered @@ -38,4 +40,4 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 ## Related -第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 +第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 71e1a28b05..2bcaa5e628 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -173,12 +173,14 @@ describe('web e2e: agent-preset selection', () => { }) it('re-reads the slash catalog through the composition the switch installed', async () => { + // Continues the previous case: the chip has already applied `minimal` to + // the blank session, and this one reads the menu that switch left behind. onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog')) const composer = page.locator('textarea:enabled').last() - // `minimal` (applied above) mounts neither the compaction group nor plan - // mode nor local skill discovery, so the catalog the composer warmed - // under the deployment default must not survive the switch. + // `minimal` mounts neither the compaction group nor plan mode nor local + // skill discovery, so the catalog the composer warmed under the + // deployment default must not survive the switch. await composer.fill('/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 29d5a2d4dc..16e055f0bf 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688 -README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50 +README.md: 753d1de796ba8ff20217d423555710429e9b7a75 +README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index b1c0c8e5b6..753d1de796 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index b6add06324..9b5b8ba7ce 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ce61351cca..fd601090bf 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -780,6 +780,14 @@ export class SessionManager { } return } + case 'host/session-preset-changed': { + // Every connected client observes the switch here; only the tab that + // issued it also gets the RPC echo. The merge keeps the row's own + // updatedAt and lowers `blank` only, so re-applying the switching + // tab's own frame is a no-op. + this.noteAgentPreset(frame.sessionId, frame.agentPreset) + return + } case 'host/session-removed': { const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId) const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index e7d702f40a..3b25ff849c 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -87,6 +87,23 @@ describe('list store projection', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') }) + it('learns a preset switch from the host frame, not only from the tab that issued it', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) + + // Every connected client gets this frame; only the switching tab gets the + // RPC echo. A client that ignored the payload would keep labelling the + // session with the composition it replaced. + b.svc.handleHostEnvelope({ + rpcId: 'r1' as never, + payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never, + }) + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true) + }) + it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index f17f3950d2..866ff89c9d 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -124,7 +124,7 @@ export class CommandService extends Service implements CommandServiceContract { warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') ctx.on('commands/changed', () => { this.directory.invalidateAll() }) - // A preset switch changes which commands ONE session's agent resolves and + // A preset switch changes which commands one session's agent resolves and // registers nothing globally, so the registry-wide signal above never // fires for it: repull that key alone, soft, so the old snapshot serves // the menu until the new one lands. diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 43607816b3..351ea4115e 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -137,8 +137,9 @@ export type HostFrame = * re-parents that agent's scope without registering anything, so a * preset already mounted for another session produces no registry change * at all. Clients refetch the catalogs this session's composition decides - * (`command.list`, `skill.list`) for this sessionId alone; the preset id - * rides along for surfaces that label the session. + * (`command.list`, `skill.list`) for this sessionId alone, and fold the + * preset id into their session row — the RPC echo reaches only the client + * that issued the switch, so the row is where every other one learns it. */ | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } /** From 298ae62a173078e1134363bcf5f7e5028068af72 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 15:16:53 +0800 Subject: [PATCH 73/81] docs(scripts): name the event-matrix collector's client-face blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated matrix under-reports client-face listeners because the program is seeded from the host aggregate alone, so `session/preset-changed` lists `ui-command` without `ui-skill` — the same shape as the existing `connection/reset` and `models/changed` rows. Record it where the collector lives, with what closing it actually takes. --- scripts/gen-doc-graphs.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 26e9233f82..cbb73fdcce 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -734,7 +734,18 @@ type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExp */ const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch']) -/** Collect event dispatch/listener relations from real cross-file receiver types. */ +/** + * Collect event dispatch/listener relations from real cross-file receiver types. + * + * TODO: the program is seeded from the host aggregate alone (ts-project.ts + * documents why: one program cannot hold both faces' Context merges), so a + * Client package enters only when a host file imports it. Client-face + * listeners on client-face events are therefore under-reported — + * `connection/reset` omits `ui-skill`/`ui-agent-preset`, `models/changed` + * omits `ui-model`, `session/preset-changed` omits `ui-skill`. Closing it + * needs a second Client program whose relations merge into these, not a + * wider seed. + */ export class EventRelationCollector { private readonly relations = new Map<string, EventRelation>() private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>() From 37ebe87087c75d628e551775d90247ae211dbe23 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 15:27:10 +0800 Subject: [PATCH 74/81] fix(tool-tasks): claim completion notices only for the mount's own scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the task registry to the host plane put every preset's `tool-tasks` listener on ONE `LocalTaskService`. `settle()` computes a single snapshot and walks every registered listener with no scope filter, and it marks `reported` only when a waiter is present — so a task settling without a waiter reached each mount's listener with `reported` false and every one of them injected the same completion into the same owner. Three shipped presets carry `tool-tasks`, and a preset file edit adds a second generation of the same mount, so an agent read N copies of one notice as model-visible durable context. A mount now claims an owner only when the owner's scope chain reaches the mount's own scope. An unscoped mount is the host-plane instance that serves every agent, which keeps the TUI composition and every existing test intact. Registry-side ownership was the alternative: mark `reported` once the first listener claims it. It is wrong because `onTaskDone` is not a notice-only seam — the `dsh-tasks` invariant companion registers a validating listener — so first-claim-wins would silence observers that are not delivering anything. The regression test mounts two scoped `tool-tasks` over one registry and settles an unowned-wait task, which is the only path that reaches the notice listeners at all: the shipped-composition e2e uses `wait: true`, and a waiter marks `reported` before settlement, so that test structurally cannot cover it. Also corrects the standing-mounts Agent Note, which still listed `tasks-local` among the stateful PRESET plugins. Refs #2141 --- ...08-08-per-preset-standing-mounts.i18n.yaml | 4 +- .../2026-08-08-per-preset-standing-mounts.md | 2 +- ...026-08-08-per-preset-standing-mounts.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 + packages/tasks/tool-tasks/README.zh.md | 2 + packages/tasks/tool-tasks/package.json | 2 + packages/tasks/tool-tasks/src/index.ts | 12 ++++- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 49 +++++++++++++++++++ packages/tasks/tool-tasks/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 17 files changed, 88 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index d8c55c9f0a..410e209e28 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971 -2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5 +2026-08-08-per-preset-standing-mounts.md: c2792454f90a88cd6fba36eed8e36104e5fffea4 +2026-08-08-per-preset-standing-mounts.zh.md: 47668c8c2c424eb188aa14bf55986d27bcfb8ee0 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 834d645f5f..c2792454f9 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -16,7 +16,7 @@ A preset is one composition per PROCESS, not one per session. The roster mounts Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap -- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. +- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite. `tasks-local` shared that property and has since left the preset plane entirely: producers outside its realm (`tool-bash`, `tool-pty`, a non-continuable `tool-subagent`) resolve the registry with `ctx.get`, which an entry-local realm hides from them, so it is composed on the host plane and only the model-facing `tool-tasks` row stays per preset. - Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: <name>: true`) keep two presets' same-named services apart exactly as they kept two sessions' apart. - A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 45ce0f4e7d..47668c8c2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -16,7 +16,7 @@ Status: implemented 常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因: -- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`、`tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。 +- 有状态的 preset 插件(`plan-mode`、`token-meter`、`compact-basic`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。`tasks-local` 同样具备该性质,且此后已完全离开 preset 平面:realm 之外的生产方(`tool-bash`、`tool-pty`、非 continuable 的 `tool-subagent`)以 `ctx.get` 解析该注册表,而 entry-local realm 对它们不可见,因此它组合在宿主平面,只有面向模型的 `tool-tasks` 行仍留在各 preset 中。 - preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry,其 entry 本地 realm(`isolate: <name>: true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。 - 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错,label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 60c6e85cca..680f6afa42 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 -config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca +config-catalog.md: f4d275393dd918f1391d33db19222e4e62e80b96 +config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18980d22c6..f4d275393d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a43c561806..9b3cf689c8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..229c8d1cd6 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 +module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..0f76bfd2dd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -947,6 +947,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1390,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..7fa0eb7266 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -949,6 +949,7 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention + pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1392,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 24e6b6892a..8c97357246 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md -README.md: 6e8e889c2330d6991cb384674b011e4d2e988268 -README.zh.md: 355b6736b476fb2434f17ea3857544f32c40adb3 +README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 +README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 6e8e889c23..1b63ba7124 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,6 +20,8 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice. +One host registry may carry several mounts of this plugin — one per agent preset — and the registry broadcasts each settlement to every mount. A scoped mount delivers only to owners composed under its own scope, so an agent reads exactly one notice per completion however many presets are mounted; an unscoped mount is the host-plane instance and delivers to every owner. + ## Config | key | default | meaning | diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md index 355b6736b4..946ba9156c 100644 --- a/packages/tasks/tool-tasks/README.zh.md +++ b/packages/tasks/tool-tasks/README.zh.md @@ -20,6 +20,8 @@ 一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。 +一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份——而注册表会把每次结算广播给全部挂载。带 scope 的挂载只向在其自身 scope 下组合出的所有者交付,因此无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知;不带 scope 的挂载是宿主平面实例,向每个所有者交付。 + ## 配置 | key | 默认值 | 含义 | diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index dfffbf4855..b910dd6a39 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -32,6 +32,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 5372fffdb2..68e8a68ba5 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -10,6 +10,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' +import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { TaskId } from '@deepseek-ai/dsh-tasks' @@ -226,12 +227,21 @@ export function apply(ctx: Context, config: Config): void { text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.', }) - // Use the exact lifecycle owner; reusable ids could resolve to a replacement. // Delivery targets the exact lifecycle owner. The notice waits in its // next-step inbox until another step claims it; disposal before that // boundary discards it with the owner. + // + // One host registry can carry SEVERAL mounts of this plugin — one per agent + // preset — and `settle()` broadcasts a single snapshot to every registered + // listener with no scope filter of its own. Each mount must therefore claim + // only the owners composed under it, or every mounted preset injects the + // same completion into the same agent and the model reads N copies of one + // notice. An unscoped mount is the host-plane instance that serves every + // agent, so it claims all of them. + const mountScope = scopeOf(ctx) ctx.tasks.onTaskDone((snapshot, owner) => { if (snapshot.reported || owner === undefined) return + if (mountScope !== undefined && !scopeChainOf(scopeOf(owner.ctx)).includes(mountScope)) return owner.inject(createUserMessage({ content: [{ type: 'text', diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 0f1bce7ecf..34b0581c12 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -6,6 +6,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import { TaskId } from '@deepseek-ai/dsh-tasks' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -445,6 +446,54 @@ describe('tool-owned UI presentation (presentCall)', () => { }) }) +describe('completion notices across scoped mounts', () => { + /** + * Two agent presets mounting `tool-tasks` over ONE host registry: each mount + * registers its own `onTaskDone` listener on the shared service, and + * `settle()` broadcasts one snapshot to every listener with no scope filter. + * Only the mount whose scope the owner belongs to may deliver the notice. + */ + it('delivers one notice from the owning scope when two mounts share the registry', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + + const standingA = createScope(ctx, {}) + const standingB = createScope(ctx, {}) + await standingA.ctx.plugin(ToolTasks) + await standingB.ctx.plugin(ToolTasks) + + // The agent joins preset A exactly as `agentPresets.compose` binds it. + const agentKey = {} + const agentScope = createScope(ctx, agentKey) + bindScopeParent(agentKey, scopeOf(standingA.ctx) as object) + + const inject = vi.fn() + const owner = { + id: SessionId('sess-scoped'), + ctx: agentScope.ctx, + inject, + session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } }, + } as unknown as Agent + const dispose = ctx.agents.register(owner) + + try { + // No waiter: `settle()` leaves `reported` false, which is the only path + // that reaches the notice listeners at all. + const p = producer({ owner, label: 'pnpm test' }) + ctx.tasks.start(p.spec) + p.settle({ status: 'completed', detail: 'exit code: 0' }) + await tick() + + expect(inject).toHaveBeenCalledTimes(1) + } finally { + dispose() + } + }) +}) + describe('completion notices', () => { it('injects a notice into the owning agent when an unreported task settles', async () => { const { ctx } = await setup() diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json index cff642796c..497860371f 100644 --- a/packages/tasks/tool-tasks/tsconfig.json +++ b/packages/tasks/tool-tasks/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../core/scope" + }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 072064f91e..4f00b4f41d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6900,6 +6900,9 @@ importers: '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 34c5da26b883477cb8ecf99e2d5bc7c30810d165 Mon Sep 17 00:00:00 2001 From: creatixchu <creatixchu@deepseek.com> Date: Mon, 10 Aug 2026 15:53:15 +0800 Subject: [PATCH 75/81] test: align apiproxy model harness with path-only workspaces --- packages/host/apiproxy/tests/api-proxy-models.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index ff3a775a2b..bdd21128b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -156,7 +156,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const result = await api.sessions.prompt(request({ @@ -203,7 +202,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const image = { type: 'image' as const, @@ -246,7 +244,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) agent.session.append('agent/inbox/spliced', { target: 'next-turn', From 59e759ce13738fe7e61fd468a509d3f07de079bc Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 16:12:41 +0800 Subject: [PATCH 76/81] fix(tasks-local): layer control surfaces and listeners by registering scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One host registry serves every composition in the process, so its two service-wide collections answered per-owner questions process-wide. `start()` asked only whether SOME surface was attached, so an agent whose own composition loads no `tool-tasks` could start work it has no tool to collect or stop as soon as any other preset attached one — and the answer changed depending on which sessions happened to be open. `settle()` walked every registered listener, so a task settling without a waiter injected one completion notice per mounted preset into the same owner. Both collections now sit in `ScopedLayers`, the layered-registry primitive `tools` and `skills` already use: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. A surface or listener registered from an unscoped context lands in the global layer and serves every owner, which is exactly the host-plane composition's own controls, so the TUI path is unchanged without a special case. This supersedes the consumer-side filter in the previous commit. That filter produced the right notices but sat in the wrong layer: it left the `start()` gate process-wide, it could not be enforced against a producer that resolves the registry directly, and it made a Consumer carry scope knowledge that the other layered registries keep in the registry. `tool-tasks` is scope-agnostic again and the `dsh-scope` edge moves to `tasks-local`. `start()`'s refusal is now owner-relative, so its model-visible text names the agent rather than the process. The shipped `minimal` preset keeps `enableRunInBackground: false`, no longer as the safety boundary — the registry owns that now — but so an agent that could never collect a task is not offered the parameter at all. Refs #2141 --- .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 2 +- .../2026-07-26-task-registry-seam.zh.md | 2 +- .../agent-presets/minimal/agent.cordis.yml | 10 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 6 +- docs/module-graph.zh.md | 6 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 16 ++-- docs/subsystems/tasks.zh.md | 16 ++-- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- packages/bash/tool-pwsh/tests/tools.spec.ts | 2 +- .../tool-cordis/src/api-catalog.ts | 4 +- .../tool-subagent/tests/tool-subagent.spec.ts | 2 +- packages/tasks/tasks-local/README.i18n.yaml | 4 +- packages/tasks/tasks-local/README.md | 2 + packages/tasks/tasks-local/README.zh.md | 2 + packages/tasks/tasks-local/package.json | 2 + packages/tasks/tasks-local/src/index.ts | 87 ++++++++++++++++--- .../tasks/tasks-local/tests/tasks.spec.ts | 73 ++++++++++++++-- packages/tasks/tasks-local/tsconfig.json | 3 + packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 4 +- packages/tasks/tasks/README.zh.md | 4 +- packages/tasks/tasks/src/index.ts | 21 +++-- packages/tasks/tool-tasks/README.i18n.yaml | 4 +- packages/tasks/tool-tasks/README.md | 2 +- packages/tasks/tool-tasks/README.zh.md | 2 +- packages/tasks/tool-tasks/package.json | 2 - packages/tasks/tool-tasks/src/index.ts | 14 +-- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 2 +- packages/tasks/tool-tasks/tsconfig.json | 3 - pnpm-lock.yaml | 6 +- 36 files changed, 232 insertions(+), 97 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index fc8cf85334..5236b342a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md -2026-07-26-task-registry-seam.md: 45801505f1729ec6094900acf94b4c17ed92c3b1 -2026-07-26-task-registry-seam.zh.md: 8dd90b34d2da1d22caba13fe8c388dab4a29be0d +2026-07-26-task-registry-seam.md: 4487bd9c53595fa8b4eed588b294ceafe3ab58dc +2026-07-26-task-registry-seam.zh.md: 6195dc809e84852c7e0f63ac101ba0ed6a46853e diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index 45801505f1..4487bd9c53 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -12,7 +12,7 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s `tasks/` is now a three-package capability family in the bash-trio shape: -- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no control surface is attached. +- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached control surface serves the spec's owner (surfaces and listeners are scope-layered, so one process-wide registry answers both questions per owner). - **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the Service Definition package has no provider dependencies. - **`@deepseek-ai/dsh-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider types. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index 8dd90b34d2..6195dc809e 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -12,7 +12,7 @@ Status: implemented `tasks/` 如今是一个 bash 三件套形态的三包能力家族: -- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。 +- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的控制接口服务于 spec 的所有者时 `start` 拒绝启动工作(控制接口与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 - **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;Service Definition 包不含任何提供方依赖。 - **`@deepseek-ai/dsh-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。 diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index cbccafe160..6ae88b9339 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -23,11 +23,11 @@ # from here; the executor behind it (`bash-sandbox`) is host-plane too, where the # sandbox policy owns it. # -# `run_in_background` is off because this preset mounts no `tool-tasks`: the -# host task registry gates starts on SOME control surface being attached, and -# that set is process-wide, so another preset's controls would let this agent -# start work it has no `task_output` to collect. Disabling drops the parameter -# from the schema too, which is the honest surface for a two-tool benchmark. +# `run_in_background` is off because this preset mounts no `tool-tasks`. The +# host registry already refuses a start for an owner no attached control +# surface serves, so this is not the safety boundary — it is the model-facing +# one: an agent that could never collect a task should not be offered the +# parameter at all, and disabling it drops the parameter from the schema. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' config: diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 680f6afa42..60c6e85cca 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: f4d275393dd918f1391d33db19222e4e62e80b96 -config-catalog.zh.md: 9b3cf689c8d4c23d1cca1260b7e74c911558d678 +config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 +config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4d275393d..18980d22c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2332,7 +2332,7 @@ export interface Config { } ``` -Source: [`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) +Source: [`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9b3cf689c8..a43c561806 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2333,7 +2333,7 @@ export interface Config { } ``` -来源:[`packages/tasks/tool-tasks/src/index.ts:24`](../packages/tasks/tool-tasks/src/index.ts) +来源:[`packages/tasks/tool-tasks/src/index.ts:23`](../packages/tasks/tool-tasks/src/index.ts) ## `@deepseek-ai/dsh-tool-todo` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 229c8d1cd6..e973aeacf7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 0f76bfd2dd700d81e2c6fb6faec3d2c0c9655e98 -module-graph.zh.md: 7fa0eb72666e63e72109a272cdc9ce323c60d2fd +module-graph.md: 9cb734066beb6c8f721c57d8d2cae29ff299ae66 +module-graph.zh.md: 3005ce3143d2ba89e2808e048b18224c438ece60 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0f76bfd2dd..9cb734066b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -730,6 +730,7 @@ flowchart TD pkg_session_title_llm --> pkg_timeout pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_scope pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout pkg_token_meter --> pkg_compact @@ -947,7 +948,6 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention - pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1353,7 +1353,7 @@ flowchart TD | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1391,7 +1391,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7fa0eb7266..3005ce3143 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -732,6 +732,7 @@ flowchart TD pkg_session_title_llm --> pkg_timeout pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants + pkg_tasks_local --> pkg_scope pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout pkg_token_meter --> pkg_compact @@ -949,7 +950,6 @@ flowchart TD pkg_tool_tasks --> pkg_invariants pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention - pkg_tool_tasks --> pkg_scope pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks pkg_tool_tasks --> pkg_tools @@ -1355,7 +1355,7 @@ flowchart TD | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1393,7 +1393,7 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..37b776d609 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tasks.md -tasks.md: d3e92891a6736a85519b97775ebe7aa6f12a5ae2 -tasks.zh.md: fe54d7a6482743a8f2ef31b143edb25afe0a478b +tasks.md: 51b21b6d81905f1dede2a1c748a417425d2aaf4e +tasks.zh.md: 045f4e438b2b384439826ecca0782349d7aaae27 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..51b21b6d81 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -172,7 +172,7 @@ Implementations must honor these semantics: - Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. - Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome. -- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop. +- start refuses work while no attached control surface serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. ```ts cordis-catalog /** @@ -237,17 +237,19 @@ abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'alrea abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ @@ -256,5 +258,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..045f4e438b 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -172,7 +172,7 @@ Implementations must honor these semantics: - Registrations outlive producer and control-surface fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. - Owned-task access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, one round of contained listener notification, and released waiters, even against a late producer outcome. -- start refuses work while no control surface is attached, so a producer cannot start work that callers cannot collect or stop. +- start refuses work while no attached control surface serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. ```ts cordis-catalog /** @@ -237,17 +237,19 @@ abstract kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'alrea abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ @@ -256,5 +258,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4f913d78ee..9a1698ac1d 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -536,7 +536,7 @@ describe('background execution through the task runtime', () => { const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no process ever ran. expect((ctx.bash as CountingStartExecutor).starts).toBe(0) }) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 91ad796ce5..7ecdbcd5f2 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -777,7 +777,7 @@ describe('background execution through the task runtime', () => { const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no process ever ran. expect(bash.startCalls).toBe(0) }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 86def75601..de53d4ae47 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1122,11 +1122,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', - jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', + jsDoc: '/**\n * Register an effect-scoped completion listener. It receives the settlements\n * of the owners its registering context\'s scope covers; each listener is\n * contained; returned promises are observed but not awaited. No listener runs\n * after service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, { signature: 'abstract attachSurface(name: string): () => void', - jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', + jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. It serves the\n * owners its registering context\'s scope covers, and {@link start} refuses an\n * owner no attached surface serves.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', }, ], }, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 91dc423cd4..04b127a856 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1027,7 +1027,7 @@ describe('background preflight failure (no orphaned child, by construction)', () agent: parent, }) expect(result.isError).toBe(true) - expect(text(result)).toContain('no control surface is attached') + expect(text(result)).toContain('no control surface serves this agent') // Declare-then-execute: the failed preflight means no child ever existed. expect(starts).toBe(0) }) diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index d4fa5d09bf..3a43cba8f8 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tasks-local/README.md -README.md: 663ce0d333c6df0f84900a2570d5487d8d7abe55 -README.zh.md: a5d1acaa50f63dc95b7607657b157272c15a4f11 +README.md: 80d7932466188955ade1c14e4968d51b739ba818 +README.zh.md: 5e4263e4685be64f91ec2a7c74edbf89e1148866 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 663ce0d333..80d7932466 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -12,6 +12,8 @@ Service disposal closes listeners, cancels all live tasks, awaits their records, Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, notifies listeners once with per-listener containment, and releases waiters. Pending waits mark the task reported before listeners run so completion surfaces do not duplicate notices. +Surfaces and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered. + ## Model Experience Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices. diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index a5d1acaa50..5e4263e468 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -12,6 +12,8 @@ 结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,也只通知监听器一次;各监听器的故障会单独隔离,随后释放等待方。挂起的等待会在监听器运行前把任务标记为已报告,因此呈现完成情况的表层不会重复发出通知。 +表层与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何表层的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。 + ## 模型体验 通过生产方插件和 [`dsh-tool-tasks`](../tool-tasks/README.md) 间接影响;它们会呈现任务 id、输出、状态、取消和完成通知。 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 446b5825d3..92e095ffa9 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -35,6 +36,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 60a7beb012..146d3e4ca7 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -11,6 +11,8 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' @@ -49,6 +51,21 @@ function isTerminal(status: TaskStatus): boolean { return status === 'completed' || status === 'killed' || status === 'failed' } +/** + * One scope's contributions: the control surfaces attached from it and the + * completion listeners registered there. Both tables are anonymous because a + * contribution is identified by its own disposer, never by a name a second + * registrant could shadow. + */ +class TaskLayer implements ScopeLayer { + readonly surfaces = new AnonymousEntries<symbol>() + readonly listeners = new AnonymousEntries<TaskDoneListener>() + + isEmpty(): boolean { + return this.surfaces.isEmpty() && this.listeners.isEmpty() + } +} + /** * The in-memory `tasks` registry. See the Service Definition contract in * `@deepseek-ai/dsh-tasks` for the ownership, isolation, and lifecycle @@ -57,8 +74,19 @@ function isTerminal(status: TaskStatus): boolean { export class LocalTaskService extends TaskService { private store = new Map<TaskId, TrackedTask>() private counters = new Map<string, number>() - private surfaces = new Set<symbol>() - private listeners = new Set<TaskDoneListener>() + /** + * Surfaces and listeners layered by the scope that registered them, in the + * tools-registry shape: a contribution files into its registering context's + * scope, and a read unions the global layer with the reader's scope chain. + * + * The registry is one process-wide instance serving every composition, so a + * flat table would answer a per-owner question process-wide: one preset's + * task controls would hold `start()` open for an agent whose own composition + * loads none, and one settlement would reach every preset's notice listener. + * Layers make both reads owner-relative. Nothing derives a cache from a + * layer, so change notification is a no-op. + */ + private readonly layers = new ScopedLayers<TaskLayer>(() => new TaskLayer(), () => {}) private listenersClosed = false /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map<Agent, () => Promise<void> | void>() @@ -72,8 +100,8 @@ export class LocalTaskService extends TaskService { } start(spec: TaskStart): TaskId { - if (this.surfaces.size === 0) { - throw new Error('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + if (!this.servesOwner(spec.owner)) { + throw new Error('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)') } if (spec.kind.length === 0) throw new Error('invalid task kind: expected a non-empty string') if (spec.label.length === 0) throw new Error('invalid task label: expected a non-empty string') @@ -210,23 +238,53 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.ctx.effect(() => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - }, 'tasks.onTaskDone()') + const dispose = this.layers.effect( + this.ctx, + layer => layer.listeners.append(listener), + { label: 'tasks.onTaskDone()' }, + ) return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.ctx.effect(() => { - this.surfaces.add(token) - return () => this.surfaces.delete(token) - }, 'tasks.attachSurface()') + const dispose = this.layers.effect( + this.ctx, + layer => layer.surfaces.append(token), + { label: 'tasks.attachSurface()' }, + ) return () => void dispose() } + /** + * Whether an attached control surface can collect and stop work owned by + * `owner`. The global layer holds every surface attached from an unscoped + * context — a host composition's own controls — and therefore serves every + * owner; a scoped surface serves exactly the agents composed under it. + * @param owner - the task's owner, or undefined for unowned work. + * @returns whether some reachable surface serves the owner. + */ + private servesOwner(owner?: Agent): boolean { + if (!this.layers.global.surfaces.isEmpty()) return true + return this.layers.chainLayers(owner === undefined ? undefined : scopeOf(owner.ctx)) + .some(layer => !layer.surfaces.isEmpty()) + } + + /** + * The completion listeners that own `owner`'s notices: the global layer's + * first, then each scoped layer along the owner's chain. A listener outside + * that chain belongs to another composition and must not deliver, or the + * owner reads one notice per mounted preset. + * @param owner - the settled task's owner, or undefined for unowned work. + * @returns the listeners to notify, in registration order per layer. + */ + private *listenersFor(owner?: Agent): IterableIterator<TaskDoneListener> { + yield* this.layers.global.listeners.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values() + } + /** Look up a task or fail loud. */ private expect(id: TaskId): TrackedTask { const task = this.store.get(id) @@ -276,7 +334,7 @@ export class LocalTaskService extends TaskService { if (task.waiters > 0) task.reported = true if (!this.listenersClosed) { const snapshot = this.snapshot(task) - for (const listener of this.listeners) { + for (const listener of this.listenersFor(task.owner)) { try { const returned = listener(snapshot, task.owner) void Promise.resolve(returned).catch((error: unknown) => { @@ -330,8 +388,9 @@ export class LocalTaskService extends TaskService { * effects. Throwing cancels are force-failed to avoid teardown deadlock. */ private async disposeAll(): Promise<void> { + // The flag is the whole guard: each layer entry's undo belongs to the fiber + // that registered it, so this service may not drop them on its own way out. this.listenersClosed = true - this.listeners.clear() const all = [...this.store.values()] this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..bdaa31d975 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -3,6 +3,8 @@ import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' @@ -15,9 +17,18 @@ declare module '@deepseek-ai/dsh-tasks' { const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>() -function stubAgent(ctx: Context, rawId: string): Agent { +function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) + // `presetScope` reproduces what `agentPresets.compose` does: the agent gets + // its own key parented to the standing mount's, so the registry's chain walk + // reaches that preset's layer. + let agentCtx = scopeFiber.ctx + if (presetScope !== undefined) { + const key = {} + bindScopeParent(key, presetScope) + agentCtx = createScope(scopeFiber.ctx, key).ctx + } const session = Session.create(id) const agent = { id, @@ -25,7 +36,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle' as const, - ctx: scopeFiber.ctx, + ctx: agentCtx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -73,6 +84,21 @@ async function harness() { return ctx } +/** + * Attach a control surface the way `tool-tasks` does: from a plugin whose own + * `inject` resolves `ctx.tasks`, so the service method binds to the REGISTERING + * context and the surface files into that context's scope layer. Reading the + * service off a bare scoped context instead throws `cannot get property "tasks" + * without inject`, which is the same rule the shipped plugin obeys. + * @param ctx - the context whose scope should own the surface. + */ +async function attachSurfaceIn(ctx: Context): Promise<void> { + await ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { pluginCtx.tasks.attachSurface('tool-tasks') }, + }) +} + /** Let the settlement continuation (a `done.then`) run. */ const tick = () => new Promise<void>(r => setTimeout(r, 0)) @@ -89,11 +115,48 @@ describe('LocalTaskService.start', () => { expectTypeOf<TaskSnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>() }) - it('refuses to register while no control surface is attached', async () => { + it('refuses to register while no control surface serves the owner', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService) expect(() => ctx.tasks.start(producer().spec)) - .toThrow('background tasks unavailable: no control surface is attached (load @deepseek-ai/dsh-tool-tasks)') + .toThrow('background tasks unavailable: no control surface serves this agent (load @deepseek-ai/dsh-tool-tasks in its composition)') + }) + + it('refuses an owner whose own composition attaches no surface', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + // Two standing preset mounts over one registry; only the first loads the + // task controls. The second must not inherit the first's open gate. + const withControls = createScope(ctx, {}) + const withoutControls = createScope(ctx, {}) + await attachSurfaceIn(withControls.ctx) + + const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx)) + const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx)) + ctx.agents.register(served) + ctx.agents.register(unserved) + + expect(() => ctx.tasks.start(producer({ owner: served }).spec)).not.toThrow() + expect(() => ctx.tasks.start(producer({ owner: unserved }).spec)) + .toThrow('no control surface serves this agent') + // An unowned producer has no chain to walk, so only a global surface serves it. + expect(() => ctx.tasks.start(producer().spec)) + .toThrow('no control surface serves this agent') + }) + + it('lets a surface attached without a scope serve every owner', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + // The host-plane composition's own controls: no scope, so the global layer + // holds them and every owner's read includes it. + await attachSurfaceIn(ctx) + const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx)) + ctx.agents.register(scoped) + + expect(() => ctx.tasks.start(producer({ owner: scoped }).spec)).not.toThrow() + expect(() => ctx.tasks.start(producer().spec)).not.toThrow() }) it('rejects an empty kind, empty label, and invalid output limit', async () => { @@ -757,6 +820,6 @@ describe('LocalTaskService disposal', () => { detachA2() expect(() => ctx.tasks.start(producer().spec)).not.toThrow() // b remains await fiber.dispose() // detaches b with its fiber (HMR safety) - expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') + expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) }) diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json index 147e3915bc..4e9a3e20bf 100644 --- a/packages/tasks/tasks-local/tsconfig.json +++ b/packages/tasks/tasks-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/scope" + }, { "path": "../../util/timeout" }, diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 94f8993c2c..95bb5a3889 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tasks/README.md -README.md: 04ca125580104d0cb5ab0ce8cdd20d104c4a1668 -README.zh.md: b0b079566246549c7acab7a83fcc3c21c44ef868 +README.md: f23a93e3cb1fc5aad5bad053f832bb66baa8eb64 +README.zh.md: c3ea9125c4db9f2d86722cbf490b4f0eecfbe25a diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 04ca125580..f23a93e3cb 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,7 +12,9 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. - `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. - `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. -- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached. +- `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when no attached surface serves the spec's owner. + +Both registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index b0b0795662..c3ea9125c4 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,7 +12,9 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 -- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 +- `attachSurface(name)` 在其 effect 生命周期内声明控制表层。当没有任何已附加的表层服务于 spec 的所有者时,`start()` 会在生产方执行前失败。 + +这两类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 5bfbe4e5a8..8c49ec8445 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -44,8 +44,13 @@ declare module 'cordis' { * - Settlement is first-wins: one terminal record, one round of contained * listener notification, and released waiters, even against a late * producer outcome. - * - {@link start} refuses work while no control surface is attached, so a - * producer cannot start work that callers cannot collect or stop. + * - {@link start} refuses work while no attached control surface serves the + * spec's owner, so a producer cannot start work that owner cannot collect + * or stop. One registry serves every composition in the process, so this + * question — and completion-listener delivery — is owner-relative rather + * than process-wide: registrations made from an unscoped context serve + * every owner, and registrations made under an agent composition's scope + * serve exactly the agents composed under it. */ export abstract class TaskService extends Service { constructor(ctx: Context) { @@ -120,17 +125,19 @@ export abstract class TaskService extends Service { abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot> /** - * Register an effect-scoped completion listener. Each listener is contained; - * returned promises are observed but not awaited. No listener runs after - * service disposal. + * Register an effect-scoped completion listener. It receives the settlements + * of the owners its registering context's scope covers; each listener is + * contained; returned promises are observed but not awaited. No listener runs + * after service disposal. * @param listener - receives each terminal snapshot and its exact owner. * @returns disposer that unregisters the listener. */ abstract onTaskDone(listener: TaskDoneListener): () => void /** - * Attach an effect-scoped surface that can read and stop tasks. {@link start} - * refuses work while none is attached. + * Attach an effect-scoped surface that can read and stop tasks. It serves the + * owners its registering context's scope covers, and {@link start} refuses an + * owner no attached surface serves. * @param name - diagnostic label; duplicate names remain independent. * @returns disposer that detaches this surface. */ diff --git a/packages/tasks/tool-tasks/README.i18n.yaml b/packages/tasks/tool-tasks/README.i18n.yaml index 8c97357246..aba9cf38d6 100644 --- a/packages/tasks/tool-tasks/README.i18n.yaml +++ b/packages/tasks/tool-tasks/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/tasks/tool-tasks/README.md -README.md: 1b63ba7124e9bdbfbf64d70e36e90d1ff13a27c8 -README.zh.md: 946ba9156c4a9d8902f8c47deb6056b2f6525f86 +README.md: 4e8872b087bac6576a3b48acbf079d3f06f3a131 +README.zh.md: 3beeb1b70c3a757f5935b0621c9648e0a02c7aa5 diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 1b63ba7124..4e8872b087 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -20,7 +20,7 @@ When a producer supplies `outputLimitBytes`, `task_output`, terminal `task_kill` An unreported completion injects `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` into the exact owner's next-step inbox. When bounded, the stable id prefix and collection command outrank variable label/detail so the notice remains actionable at PTY's supported 64-byte minimum. Injection is durable pending context for a later pre-step claim, not a wake-up; cancellation or owner disposal may discard it before claim. A kill or terminal read/wait marks delivery reported and suppresses the redundant notice. -One host registry may carry several mounts of this plugin — one per agent preset — and the registry broadcasts each settlement to every mount. A scoped mount delivers only to owners composed under its own scope, so an agent reads exactly one notice per completion however many presets are mounted; an unscoped mount is the host-plane instance and delivers to every owner. +One host registry may carry several mounts of this plugin — one per agent preset. The registry routes each settlement to the listeners the owner's scope chain reaches, so a mount under one preset never sees another preset's agents and an agent reads exactly one notice per completion however many presets are mounted. The same routing decides which agents this mount's control surface serves: an agent whose composition loads no `tool-tasks` cannot start background work at all. ## Config diff --git a/packages/tasks/tool-tasks/README.zh.md b/packages/tasks/tool-tasks/README.zh.md index 946ba9156c..3beeb1b70c 100644 --- a/packages/tasks/tool-tasks/README.zh.md +++ b/packages/tasks/tool-tasks/README.zh.md @@ -20,7 +20,7 @@ 一项尚未报告的完成会把 `background task <id> (<kind>: <label>) finished [status: ...]. Read its output with task_output.` 注入到确切所有者的 next-step inbox。应用上限时,即使采用 PTY 支持的 64 字节下限,稳定 id 前缀和收集命令的优先级也高于可变 label/detail,因此通知仍可操作。注入是等待后续 pre-step 领取的持久上下文,并非唤醒;取消或 owner 释放可能在领取前丢弃它。kill 或针对已终止任务的 read/wait 会把交付标为已报告,并抑制重复通知。 -一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份——而注册表会把每次结算广播给全部挂载。带 scope 的挂载只向在其自身 scope 下组合出的所有者交付,因此无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知;不带 scope 的挂载是宿主平面实例,向每个所有者交付。 +一个宿主注册表可能承载本插件的多份挂载——每个 agent preset 一份。注册表会把每次结算路由给所有者 scope 链所能抵达的监听器,因此某个 preset 下的挂载永远看不到另一个 preset 的 agent,无论挂载了多少 preset,一个 agent 每次完成都只读到一条通知。同一套路由也决定本挂载的控制表层服务哪些 agent:组合中未加载 `tool-tasks` 的 agent 根本无法启动后台工作。 ## 配置 diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index b910dd6a39..dfffbf4855 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -32,7 +32,6 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -43,7 +42,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 68e8a68ba5..720cb51f97 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -10,7 +10,6 @@ import type { Context } from 'cordis' import z from 'schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' -import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { TaskId } from '@deepseek-ai/dsh-tasks' @@ -227,21 +226,16 @@ export function apply(ctx: Context, config: Config): void { text: 'Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task\'s work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.', }) + // Use the exact lifecycle owner; reusable ids could resolve to a replacement. // Delivery targets the exact lifecycle owner. The notice waits in its // next-step inbox until another step claims it; disposal before that // boundary discards it with the owner. // - // One host registry can carry SEVERAL mounts of this plugin — one per agent - // preset — and `settle()` broadcasts a single snapshot to every registered - // listener with no scope filter of its own. Each mount must therefore claim - // only the owners composed under it, or every mounted preset injects the - // same completion into the same agent and the model reads N copies of one - // notice. An unscoped mount is the host-plane instance that serves every - // agent, so it claims all of them. - const mountScope = scopeOf(ctx) + // The registry routes each settlement to the listeners its owner's scope + // chain reaches, so a mount under one preset never sees another preset's + // agents; this listener owns delivery, not the choice of whom to deliver to. ctx.tasks.onTaskDone((snapshot, owner) => { if (snapshot.reported || owner === undefined) return - if (mountScope !== undefined && !scopeChainOf(scopeOf(owner.ctx)).includes(mountScope)) return owner.inject(createUserMessage({ content: [{ type: 'text', diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 34b0581c12..a213ca83e5 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -86,7 +86,7 @@ describe('tool-tasks setup', () => { const { ctx, toolsFiber } = await setup() expect(() => ctx.tasks.start(producer().spec)).not.toThrow() await toolsFiber.dispose() - expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') + expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) it('rejects a config whose default wait exceeds the cap', async () => { diff --git a/packages/tasks/tool-tasks/tsconfig.json b/packages/tasks/tool-tasks/tsconfig.json index 497860371f..cff642796c 100644 --- a/packages/tasks/tool-tasks/tsconfig.json +++ b/packages/tasks/tool-tasks/tsconfig.json @@ -26,9 +26,6 @@ { "path": "../../core/system-prompt" }, - { - "path": "../../core/scope" - }, { "path": "../../core/tools" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f00b4f41d..ffb80644fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6869,6 +6869,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -6900,9 +6903,6 @@ importers: '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From ef35c7f3b227b0640097e8d8c6ffd37bd5c3e323 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 16:01:41 +0800 Subject: [PATCH 77/81] fix(feedback): include session id in acknowledgement --- packages/feedback/command-feedback/README.i18n.yaml | 4 ++-- packages/feedback/command-feedback/README.md | 2 +- packages/feedback/command-feedback/README.zh.md | 2 +- packages/feedback/command-feedback/src/index.ts | 8 ++++++-- .../command-feedback/tests/command-feedback.spec.ts | 6 +++--- .../command-feedback/tests/loader-composition.spec.ts | 5 ++++- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index d919320643..b1b1a8d4d4 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: d7849e25fc62897e4ac6793f40bdc139adf9ba3d -README.zh.md: c3b7b59d90d924de6042aeac1e7eec39457c6c83 +README.md: 96d2825f4b63c95ad6f45ca8b2e05d1fc5ae92aa +README.zh.md: 5220afe68b1f0de50fd1368900758906ee6907c9 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index d7849e25fc..96d2825f4b 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -8,7 +8,7 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The | Input | Result | |---|---| -| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded.` | +| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {id}`. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index c3b7b59d90..5220afe68b 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,7 +8,7 @@ | 输入 | 结果 | |---|---| -| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 | +| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {id}` 确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 037a463104..92ef839415 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -41,14 +41,18 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. - * @returns an acknowledgement, or a usage error when no feedback text was supplied. + * @returns an acknowledgement containing the receiving session id, or a usage error + * when no feedback text was supplied. */ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } recordFeedback(invocation.agent.session, invocation.rawInput) - return { kind: 'success', text: 'Feedback recorded.' } + return { + kind: 'success', + text: `Feedback recorded for session ${invocation.agent.session.id}`, + } } /** Register the global `/feedback` command for every composed command adapter. */ diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 145b7ccf11..19d886af00 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -93,7 +93,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: 'Feedback recorded.', + text: `Feedback recorded for session ${test.session.id}`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -141,8 +141,8 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: 'Feedback recorded.' }, - { kind: 'success', text: 'Feedback recorded.' }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 227672d167..98609afdea 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -87,7 +87,10 @@ describe('/feedback real Loader composition through cordis.yml', () => { expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) - expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' }) + expect(accepted?.result).toEqual({ + kind: 'success', + text: 'Feedback recorded for session feedback-loader-agent', + }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ kind: 'error', From 8dc91d2c00f231d14b962f27b6a6143f36258add Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Fri, 7 Aug 2026 16:38:54 +0800 Subject: [PATCH 78/81] fix(feedback): report shared anonymous user id --- ...hared-feedback-telemetry-user-id.i18n.yaml | 6 ++ ...08-07-shared-feedback-telemetry-user-id.md | 33 +++++++++++ ...07-shared-feedback-telemetry-user-id.zh.md | 33 +++++++++++ .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 4 +- .../feature/2026-07-28-feedback-command.zh.md | 4 +- ...7-31-telemetry-anonymous-user-id.i18n.yaml | 4 +- .../2026-07-31-telemetry-anonymous-user-id.md | 10 ++-- ...26-07-31-telemetry-anonymous-user-id.zh.md | 10 ++-- apps/web/tests/seeded-history.e2e.ts | 55 ++++++++++++++++--- .../seeded-history/feedback-row.expected.md | 53 ++++++++++++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 13 +++-- docs/module-graph.zh.md | 13 +++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 4 +- .../feedback/command-feedback/README.zh.md | 4 +- .../feedback/command-feedback/package.json | 2 + .../feedback/command-feedback/src/index.ts | 7 ++- .../tests/command-feedback.spec.ts | 20 +++++-- .../tests/loader-composition.spec.ts | 8 ++- .../feedback/command-feedback/tsconfig.json | 3 + .../session-telemetry-otel/package.json | 6 +- .../session-telemetry-otel/src/index.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 2 +- .../session-telemetry-otel/tsconfig.json | 5 +- packages/session/user-id/README.i18n.yaml | 6 ++ packages/session/user-id/README.md | 29 ++++++++++ packages/session/user-id/README.zh.md | 29 ++++++++++ packages/session/user-id/package.json | 39 +++++++++++++ .../src/user-id.ts => user-id/src/index.ts} | 26 ++++----- packages/session/user-id/src/invariant.ts | 31 +++++++++++ .../session/user-id/tests/invariant.spec.ts | 12 ++++ .../tests/user-id.spec.ts | 2 +- packages/session/user-id/tsconfig.json | 21 +++++++ pnpm-lock.yaml | 27 +++++++-- .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 44 files changed, 462 insertions(+), 89 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md create mode 100644 .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md create mode 100644 apps/web/tests/snapshots/seeded-history/feedback-row.expected.md create mode 100644 packages/session/user-id/README.i18n.yaml create mode 100644 packages/session/user-id/README.md create mode 100644 packages/session/user-id/README.zh.md create mode 100644 packages/session/user-id/package.json rename packages/session/{session-telemetry-otel/src/user-id.ts => user-id/src/index.ts} (79%) create mode 100644 packages/session/user-id/src/invariant.ts create mode 100644 packages/session/user-id/tests/invariant.spec.ts rename packages/session/{session-telemetry-otel => user-id}/tests/user-id.spec.ts (99%) create mode 100644 packages/session/user-id/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml new file mode 100644 index 0000000000..226b62f100 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md +2026-08-07-shared-feedback-telemetry-user-id.md: 6d4020828cb1f2ab3de0328c8959a18a0fcfe6c4 +2026-08-07-shared-feedback-telemetry-user-id.zh.md: 892fa0f848d656609885d008ab36e3ebbe09b992 diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md new file mode 100644 index 0000000000..6d4020828c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.md @@ -0,0 +1,33 @@ +# Agent Note: Shared feedback and telemetry anonymous user id + +Status: implemented + +English | [中文](2026-08-07-shared-feedback-telemetry-user-id.zh.md) + +## Problem + +The OpenTelemetry backend already persisted one anonymous UUID in `$DSH_HOME/.userid`. `/feedback` now needs to report both the receiving session id and a user id so an operator can correlate the acknowledgement with exported records. Duplicating or independently generating that identity would make the reported user meaningless, while importing it from `session-telemetry-otel` would make a direct command depend on an exporter backend and create a dependency cycle when feedback export is mounted by telemetry. + +The earlier [anonymous-user-id decision](../feature/2026-07-31-telemetry-anonymous-user-id.md) deliberately kept the helper inside the OTel backend until a second real consumer existed. Feedback is that consumer. + +## Decision + +`@deepseek-ai/dsh-user-id` owns `getOrCreateAnonymousUserId()` and the `$DSH_HOME/.userid` storage contract. `session-telemetry-otel` uses the returned id as OpenTelemetry Resource `user.id`; the `/feedback` success acknowledgement reports `Feedback recorded for session {sessionId}` followed by `User: {userId}` on a second line, which keeps both identifiers available through the generic command row's expandable body. Invalid feedback is rejected before resolving the id, so an empty command does not create `.userid`. + +The extraction preserves the existing random UUID, home resolution, process memo, exclusive-create concurrency, corruption replacement, and best-effort write semantics. It does not unify the dsh-sdk launcher's separate `telemetry.json` identity. + +## Alternatives considered + +| Rejected | Reason | +|---|---| +| Import the helper from `session-telemetry-otel` | Couples feedback to an optional exporter backend and forms a reverse dependency cycle once telemetry exports feedback | +| Duplicate the persistence helper in feedback | Two implementations of one file contract can drift and race with different validation or failure semantics | +| Generate a separate feedback user id | The acknowledgement could not correlate with the OTel Resource and would not satisfy the reporting purpose | +| Move the launcher telemetry id too | The launcher feed is not a consumer of `.userid`; unifying unrelated stores remains out of scope | + +## Consequences + +- One harness home now has one anonymous id shared by feedback acknowledgements and session telemetry exports. +- The feedback package depends only on the identity capability, not the telemetry seam or OTel SDK. +- The new package is a justified shared seam with two consumers; its empty invariant companion explains why reading the private file is not a useful runtime relationship check. +- The original anonymous-user-id Note remains authoritative for storage and privacy semantics, while this Note supersedes only its OTel-local ownership decision. diff --git a/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md new file mode 100644 index 0000000000..892fa0f848 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-shared-feedback-telemetry-user-id.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 反馈与遥测共享匿名用户 id + +Status: implemented + +[English](2026-08-07-shared-feedback-telemetry-user-id.md) | 中文 + +## 问题 + +OpenTelemetry 后端已在 `$DSH_HOME/.userid` 中持久化一个匿名 UUID。`/feedback` 需要同时报告接收反馈的会话 id 与用户 id,以便运维人员将确认文本与导出的记录相关联。复制该身份或单独生成身份会使报告的用户失去意义;从 `session-telemetry-otel` 导入身份则会让直接命令依赖导出后端,并在遥测侧挂载反馈导出时形成依赖环。 + +早先的[匿名用户 id 决策](../feature/2026-07-31-telemetry-anonymous-user-id.md)刻意将辅助函数留在 OTel 后端内,直至出现第二个真实消费方。反馈就是这个消费方。 + +## 决策 + +`@deepseek-ai/dsh-user-id` 负责 `getOrCreateAnonymousUserId()` 和 `$DSH_HOME/.userid` 存储契约。`session-telemetry-otel` 将返回的 id 用作 OpenTelemetry Resource 的 `user.id`;`/feedback` 的成功确认先报告 `Feedback recorded for session {sessionId}`,再在第二行显示 `User: {userId}`,使两个标识符都可通过通用命令行的可展开正文查看。系统在获取 id 前拒绝无效反馈,因此空命令不会创建 `.userid`。 + +此次抽取保留既有的随机 UUID、home 解析、进程内缓存、独占创建并发、损坏文件替换与 best-effort 写入语义。它不会统一 dsh-sdk launcher 独立的 `telemetry.json` 身份。 + +## 考虑过的替代方案 + +| 已否决 | 原因 | +|---|---| +| 从 `session-telemetry-otel` 导入辅助函数 | 使反馈耦合到可选的导出后端,并在遥测导出反馈后形成反向依赖环 | +| 在反馈中复制持久化辅助函数 | 同一文件契约的两份实现可能发生偏差,并因校验或失败语义不同而产生竞态 | +| 生成独立的反馈用户 id | 确认文本无法与 OTel Resource 相关联,因而不能达到报告目的 | +| 同时移动 launcher telemetry id | launcher 回流不是 `.userid` 的消费方;统一无关存储仍不在范围内 | + +## 后果 + +- 一个 harness home 只有一个匿名 id,由反馈确认与会话遥测导出共享。 +- 反馈包只依赖身份能力,不依赖遥测 seam 或 OTel SDK。 +- 新包由两个消费方使用,成为有充分依据的共享 seam;其空不变式伴生插件解释了为何读取私有文件并非有用的运行时关系检查。 +- 原始匿名用户 id Note 仍是存储与隐私语义的权威记录;本 Note 仅取代其中由 OTel 本地拥有身份的决策。 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index e0133df46f..809e37044f 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 78cc9e89a5811b3f1520bae2bd971cbcf2522ede -2026-07-28-feedback-command.zh.md: b0bba25ec3331123cb86066fc6c89186f2297cc9 +2026-07-28-feedback-command.md: 3edb29283c289d6d006891a4c19087b01fa8166f +2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 78cc9e89a5..3edb29283c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -12,7 +12,7 @@ The capture surface has to be usable at the moment of annoyance, which rules out ## Decision -`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback <text>` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. +`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback <text>` acknowledges with the receiving session id and the shared harness-home anonymous user id; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration. [The shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) records why feedback and OpenTelemetry use the same `$DSH_HOME/.userid` value. The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command. @@ -54,7 +54,7 @@ Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /pla ## Consequences -The shipped `dsh` base mounts the command unconditionally — no configuration, no dependency on the goal stack. The Web client exposes it through its command adapter. Headless mode, ACP, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. +The shipped `dsh` base mounts the command unconditionally — no configuration, no dependency on the goal stack. The Web client exposes it through its command adapter. Headless mode, ACP, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. The first accepted feedback for a harness home can create `$DSH_HOME/.userid`; rejected empty input does not resolve or create an id. The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior. diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index b0bba25ec3..c2513d2570 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback <text>` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。 +位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback <text>` 在确认文本中包含接收反馈的会话 id 与 harness home 的共享匿名用户 id;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)说明了反馈与 OpenTelemetry 为何使用同一个 `$DSH_HOME/.userid` 值。 本包声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。 @@ -54,7 +54,7 @@ Status: implemented ## 后果 -随附的 `dsh` 基础组合无条件挂载该命令:没有配置,也不依赖 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 +随附的 `dsh` 基础组合无条件挂载该命令:没有配置,也不依赖 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。对于某个 harness home,首次接受反馈时可能创建 `$DSH_HOME/.userid`;被拒绝的空输入不会获取或创建 id。 本包拥有一个独立的仅追加事件,不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。 diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml index 47e1e0144b..1bec2a758b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md -2026-07-31-telemetry-anonymous-user-id.md: f53fc9ce7eb3a5aefcb0be0a20c7403b5601e369 -2026-07-31-telemetry-anonymous-user-id.zh.md: 99b2dd88df94810ccfc85d099e74a6f0852153a7 +2026-07-31-telemetry-anonymous-user-id.md: 75b65e9fd477d19afb3a3a25e424a7f7620099a3 +2026-07-31-telemetry-anonymous-user-id.zh.md: 3db5b665f9cbbe6f884a9717afa758f22a419658 diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md index f53fc9ce7e..75b65e9fd4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.md @@ -10,7 +10,7 @@ Session telemetry is mounted by default ([default-mount Note](2026-07-31-web-tel ## Decision -The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel feed's user identity: `getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. This identity belongs to the OTel feed alone; the dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`), and the two are not shared (the first cut unified both feeds through a shared util package — no shared package before a second real consumer exists, revisit when a feed-correlation need appears). +`getOrCreateAnonymousUserId()` returns the bare UUID line in `$DSH_HOME/.userid` (resolved by `resolveDshHome`, `$DSH_HOME` > `~/.dsh`), minting and persisting a random UUID v4 on first use; the backend constructor carries it as the Resource's `user.id` (the OTel semconv user attribute), once per export batch. The original implementation lived inside `session-telemetry-otel` because no second real consumer existed. `/feedback` later became that consumer, so [the shared-id decision](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md) moves ownership to `@deepseek-ai/dsh-user-id` without changing the storage, anonymity, concurrency, or loss semantics recorded here. The dsh-sdk launcher telemetry keeps its own anonymous-id store (`telemetry.json`) and remains unrelated. | Ruling | Value | Rationale | |---|---|---| @@ -22,8 +22,8 @@ The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel | Write failure | Best-effort: return the in-memory id | Telemetry is never blocked by a read-only home | | Report position | Resource attribute, not per-record attributes | Once per batch suffices for Resource-dimension aggregation; per-record injection would touch the seam contract and grow the wire | | semconv dependency | `@opentelemetry/semantic-conventions` is not imported | One string constant does not justify a dependency | -| Home | A module inside `session-telemetry-otel`, not a shared util package | Repo rule: split a package only for a second real consumer; the sdk launcher feed keeps its own store, and no real correlation need exists | -| Separate switch | None | Identity follows the telemetry master switch (`DSH_TELEMETRY_DISABLED`); telemetry off means nothing reports | +| Home | `@deepseek-ai/dsh-user-id`, shared by the OTel backend and `/feedback` | The second real consumer now exists; direct feedback must not depend on an exporter backend | +| Separate switch | None | Either consumer can create the identity; `DSH_TELEMETRY_DISABLED` stops telemetry reporting but does not disable feedback acknowledgement | ## Alternatives considered @@ -31,7 +31,7 @@ The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel |---|---| | Hostname/IP-hash-derived id (the prior ruling) | Reversible means not anonymous; the random UUID is semantically clean — the user ruled to supersede | | user.id on every record's attributes (Claude Code's shape) | Touches the session-telemetry seam contract or injects per record, growing the wire; once per batch on the Resource already aggregates | -| A shared util package unifying both feeds (the first cut) | The only real consumer is the OTel backend; switching the sdk launcher onto it was unification for its own sake — the user reconsidered and pulled it back, to be re-extracted when a correlation need appears | +| A shared package before `/feedback` needed the id (the first cut) | At that time the only real consumer was the OTel backend; extraction became justified only when direct feedback needed the same correlation id | | Reusing telemetry.json instead of a new file | The file name/JSON format files the identity under the launcher feed's naming; the OTel feed's identity is a standalone fact | | AppCLIEntry reading the id and injecting via config patch | Every surface entry needs wiring; a runtime fact inside deployment config conflates the two | | Housing it in `@deepseek-ai/dsh-paths` | paths is pure path computation with zero IO; a persisting identity capability would pollute the package boundary | @@ -39,6 +39,6 @@ The `session-telemetry-otel` package's own module `src/user-id.ts` owns the OTel ## Consequences - One `$DSH_HOME` is one stable user in the OTel feed; separate homes are separate users by construction, with no cross-home linking mechanism. -- The OTel feed and the launcher feed each hold their own id (`.userid` vs `telemetry.json`) and cannot be correlated — the direct cost of not extracting a shared package, to be unified when a real correlation need appears. +- The OTel feed and `/feedback` share `.userid`; the launcher feed still uses `telemetry.json` and cannot be correlated with them. - Deleting `.userid` resets the identity (effective next launch); on an unwritable home each process holds its own in-memory id until the home becomes writable. - The [default-mount Note](2026-07-31-web-telemetry-default-mount.md)'s identity follow-up is closed for the anonymous-user-id part by this decision; hostname/surface dimensions, the redaction rule, and the usage-metrics track remain open. diff --git a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md index 99b2dd88df..3db5b665f9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-telemetry-anonymous-user-id.zh.md @@ -10,7 +10,7 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry ## Decision -`session-telemetry-otel` 包内模块 `src/user-id.ts` 是 OTel 回流用户身份的属主:`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;backend 构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。该身份只属于 OTel 回流;dsh-sdk launcher telemetry 保留自己的匿名 id 存储(`telemetry.json`),两者不共享(初版曾做公用 util 包统一两条回流——在有第二个真实消费者之前不抽公共包,回流关联需求出现时再议)。 +`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;后端构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。原始实现位于 `session-telemetry-otel`,因为当时不存在第二个真实消费方。`/feedback` 后来成为该消费方,因此[共享 id 决策](../architecture/2026-08-07-shared-feedback-telemetry-user-id.md)将所有权移交给 `@deepseek-ai/dsh-user-id`,但不改变本 Note 记录的存储、匿名、并发与丢失语义。dsh-sdk launcher telemetry 继续使用自己独立的匿名 id 存储(`telemetry.json`),与此身份无关。 | 裁定 | 取值 | 理由 | |---|---|---| @@ -22,8 +22,8 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry | 写失败 | best-effort 返回内存 id | telemetry 永不因 home 只读被阻塞 | | 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 约定且涨 wire 体积 | | semconv 依赖 | 不引 `@opentelemetry/semantic-conventions` 包 | 一个字符串常量不值一个依赖 | -| 落点 | `session-telemetry-otel` 包内模块,非公共 util 包 | 仓规「有第二个真实消费者才拆包」;sdk launcher 回流保留自有存储,无现实关联需求 | -| 单独开关 | 无 | 身份跟随 telemetry 整体开关(`DSH_TELEMETRY_DISABLED`);关 telemetry 即整体不报 | +| 落点 | `@deepseek-ai/dsh-user-id`,由 OTel 后端与 `/feedback` 共享 | 第二个真实消费方已经出现;直接反馈不能依赖导出后端 | +| 单独开关 | 无 | 任一消费方都可创建该身份;`DSH_TELEMETRY_DISABLED` 会停止遥测上报,但不会禁用反馈确认 | ## Alternatives considered @@ -31,7 +31,7 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry |---|---| | hostname/IP 哈希派生 id(此前口径) | 可反查即非匿名;随机 UUID 语义干净,用户裁决取代 | | user.id 放每条 record 的 attributes(Claude Code 形态) | 要动 session-telemetry seam 约定或逐条注入,wire 体积涨;Resource 每批一次已满足聚合 | -| 公用 util 包统一两条回流(初版实现) | 唯一现实消费者是 OTel backend;sdk launcher 换用它只是为统一而统一——用户复议收回,回流关联需求出现时再抽包 | +| 在 `/feedback` 需要该 id 之前抽取共享包(初版实现) | 当时唯一的真实消费方是 OTel 后端;只有直接反馈需要同一个关联 id 后,抽取才具备依据 | | 复用 telemetry.json 不新建文件 | 文件名/JSON 格式把身份挂在 launcher 链路命名下;OTel 回流身份是独立事实 | | AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线;config 里传运行时事实与部署配置混淆 | | 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO;带持久化的身份能力会污染包边界 | @@ -39,6 +39,6 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry ## Consequences - 一个 `$DSH_HOME` 在 OTel 回流中是一个稳定用户;不同 home 在构造上就是不同用户,无跨 home 关联机制。 -- OTel 回流与 launcher 回流各有各的 id(`.userid` 与 `telemetry.json`),无法互相关联——这是「不抽公共包」的直接代价,等真实关联需求出现再统一。 +- OTel 回流与 `/feedback` 共享 `.userid`;launcher 回流仍使用 `telemetry.json`,无法与前两者关联。 - 删除 `.userid` 即重置身份(下次启动生效);home 不可写时每进程各自持有一个内存 id 直至恢复可写。 - [默认挂载 Note](2026-07-31-web-telemetry-default-mount.md) 的身份 follow-up 中「匿名用户 id」项由本决定关闭;hostname/surface 维度与脱敏规则、usage-metrics track 仍是待办。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 80ef25ba39..257da0031a 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -3,12 +3,11 @@ // else covers: sidebar cold listing, the implicit resume/attach inside the // history RPC, history-page tool views, and the client's log-ordered transcript // events — with ZERO model calls in replay (no replay fixture; a stray stream -// fails loud on the open llm seam). The cold session also carries the one -// keyless command-row surfaces: the seeded manual `/compact` lifecycle folds -// into its checkpoint, while an Access-chip pick later runs `/permission` on -// the host. The seed is a recorded -// fixture under the -// same record discipline as every other: DSH_SNAPSHOT=record drives the turn +// fails loud on the open llm seam). The cold session also carries keyless +// command-row surfaces: the seeded manual `/compact` lifecycle folds into its +// checkpoint, an Access-chip pick later runs `/permission` on the host, and +// `/feedback` pins its expandable correlation ids. The seed is a recorded +// fixture under the same record discipline as every other: DSH_SNAPSHOT=record drives the turn // live through the composer (real read tool against seeded workspace files) // and harvests seed.jsonl; replay/refresh seed it cold and only render. import { readFile, writeFile, mkdir } from 'node:fs/promises' @@ -32,9 +31,9 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url)) -// The command-row golden: the same conversation after one /permission switch, -// which is the only surface that shows a settled command row's copy. +// Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url)) +const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -446,6 +445,44 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) }, 60_000) + it.skipIf(MODE === 'record')('reports full feedback correlation ids in an expandable two-line row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-feedback-row')) + const previousDshHome = process.env.DSH_HOME + process.env.DSH_HOME = scaffold.harnessHome + try { + const input = page.locator('textarea').first() + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + const row = page.locator('[data-variant="others"]').filter({ + hasText: `Feedback recorded for session ${SEED_ID}`, + }) + await row.waitFor({ timeout: 10_000 }) + const disclosure = row.locator('[data-expandable]') + expect(await disclosure.getAttribute('aria-expanded')).toBe('false') + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const done = agent.session.events.filter(event => event.type === 'command/done').at(-1) + if (done?.type !== 'command/done') throw new Error('feedback command did not settle') + const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] + expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) + expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(extraLine).toBeUndefined() + const userId = userLine?.slice('User: '.length) + if (userId === undefined) throw new Error('feedback command omitted the user id') + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + .split(userId).join('{{userId}}') + await compareOrRefreshGolden(FEEDBACK_ROW_EXPECTED, snapshot, MODE) + } finally { + if (previousDshHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousDshHome + } + }, 60_000) + it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') @@ -473,6 +510,6 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md new file mode 100644 index 0000000000..87b763d37c --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -0,0 +1,53 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the read tool twice" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" +- button "Copy": + - img +- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img + - img + - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - img + - img + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- paragraph: DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "compact Compacted 5 history items (~{{tokens}} tokens)" +- button "Context injection AGENTS.md": + - img + - img + - text: Context injection AGENTS.md +- img +- text: permission preset read-only +- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]': + - img + - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" +- text: "Feedback recorded for session {{seededId}} User: {{uuid}}" +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Read Only"': Read Only +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cc76a6b557..7319ee9070 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a785f856f0c5e3b1f99260e365ba5d15641dd5be -config-catalog.zh.md: 913f7d7771aa3f5e86b199121c64d5b9b00e968d +config-catalog.md: bf5bdc275e4611afaa6950078459ea34723a0d53 +config-catalog.zh.md: 0d9711d729364d2b06dbc7859f7c0a255222979f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a785f856f0..bf5bdc275e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2811,3 +2811,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-user-id` ([`packages/session/user-id/src/index.ts`](../packages/session/user-id/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 913f7d7771..0d9711d729 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2811,3 +2811,4 @@ export interface Config { - `@deepseek-ai/dsh-type-meta`([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts)) - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-user-id`([`packages/session/user-id/src/index.ts`](../packages/session/user-id/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c8cd127319..66486e97b7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 9df5eabe1c39fd8bb3e14b635e43b782c816d369 -module-graph.zh.md: ee187222fa1ed727941e7820ac8c33ed532497e3 +module-graph.md: a2407f5d394020834172288e3d03518d1e8045db +module-graph.zh.md: 364033c29d773a764ce3f8f0036edeac7c9e0b21 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9df5eabe1c..a2407f5d39 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -269,6 +269,7 @@ flowchart TD pkg_session_title_all_messages_llm["session-title-all-messages-llm"] pkg_session_title_first_message_llm["session-title-first-message-llm"] pkg_session_title_llm["session-title-llm"] + pkg_user_id["user-id"] end subgraph group_settings["packages/settings"] pkg_settings["settings"] @@ -355,6 +356,9 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_user_id --> pkg_brand + pkg_user_id --> pkg_invariants + pkg_user_id --> pkg_paths pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants @@ -711,6 +715,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -937,13 +942,12 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_brand pkg_session_telemetry_otel --> pkg_command_feedback pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_paths pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry + pkg_session_telemetry_otel --> pkg_user_id pkg_session_title_all_messages_llm --> pkg_invariants pkg_session_title_all_messages_llm --> pkg_llm pkg_session_title_all_messages_llm --> pkg_session @@ -1274,6 +1278,7 @@ flowchart TD | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/scaffold/helper) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/scaffold/telemetry) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1362,7 +1367,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1402,7 +1407,7 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ee187222fa..364033c29d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -271,6 +271,7 @@ flowchart TD pkg_session_title_all_messages_llm["session-title-all-messages-llm"] pkg_session_title_first_message_llm["session-title-first-message-llm"] pkg_session_title_llm["session-title-llm"] + pkg_user_id["user-id"] end subgraph group_settings["packages/settings"] pkg_settings["settings"] @@ -357,6 +358,9 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_user_id --> pkg_brand + pkg_user_id --> pkg_invariants + pkg_user_id --> pkg_paths pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants @@ -713,6 +717,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -939,13 +944,12 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_brand pkg_session_telemetry_otel --> pkg_command_feedback pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_paths pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry + pkg_session_telemetry_otel --> pkg_user_id pkg_session_title_all_messages_llm --> pkg_invariants pkg_session_title_all_messages_llm --> pkg_llm pkg_session_title_all_messages_llm --> pkg_session @@ -1276,6 +1280,7 @@ flowchart TD | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/scaffold/helper) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/scaffold/telemetry) | `scaffold` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1364,7 +1369,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1404,7 +1409,7 @@ flowchart TD | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7ca14e31fb..7a8f73a488 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 9953214182521ac2c1aac8b4589bad7ad45e3094 -persistence-catalog.zh.md: 730513ea259dde274c8c63948dd21fdc0b70417f +persistence-catalog.md: f1dd0f6635bbb2ed2bbf679fdab2664cef08906d +persistence-catalog.zh.md: 7a0f66b5622fbc9527947019da442b21a1b67b9a diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 9953214182..f1dd0f6635 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -364,7 +364,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact 'feedback/record': { text: string } ``` -Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 730513ea25..7a0f66b562 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -366,7 +366,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'feedback/record': { text: string } ``` -来源:[`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts) +来源:[`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index b1b1a8d4d4..ea0c591ae2 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: 96d2825f4b63c95ad6f45ca8b2e05d1fc5ae92aa -README.zh.md: 5220afe68b1f0de50fd1368900758906ee6907c9 +README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023 +README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 96d2825f4b..52b8fb6a42 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -8,7 +8,7 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The | Input | Result | |---|---| -| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {id}`. | +| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. @@ -17,7 +17,7 @@ Surrounding whitespace is discarded, but feedback is otherwise unparsed: no trun `recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract. -The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../interaction/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`. +The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../interaction/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. The acknowledgement identifies both the receiving session and the [shared anonymous user](../../session/user-id/); the first accepted feedback for a harness home can create `$DSH_HOME/.userid`. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record` and no user-id lookup. The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index 5220afe68b..ca74d53f25 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,7 +8,7 @@ | 输入 | 结果 | |---|---| -| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {id}` 确认。 | +| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 @@ -17,7 +17,7 @@ `recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。 -反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../interaction/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`。 +反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../interaction/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。确认文本同时标明接收反馈的会话和[共享匿名用户](../../session/user-id/);对于某个 harness home,首次接受反馈时可能创建 `$DSH_HOME/.userid`。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`,也不会查找用户 id。 权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 535c438a63..433087eff3 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-user-id": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 92ef839415..7f0bb3a59f 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import type { Session } from '@deepseek-ai/dsh-session' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' export const name = 'command-feedback' export const inject = ['commands'] @@ -41,8 +42,8 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. - * @returns an acknowledgement containing the receiving session id, or a usage error - * when no feedback text was supplied. + * @returns an acknowledgement containing the receiving session and anonymous + * user ids, or a usage error when no feedback text was supplied. */ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { if (invocation.rawInput.trim().length === 0) { @@ -51,7 +52,7 @@ function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { recordFeedback(invocation.agent.session, invocation.rawInput) return { kind: 'success', - text: `Feedback recorded for session ${invocation.agent.session.id}`, + text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`, } } diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 19d886af00..6f93ff854e 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' @@ -7,6 +7,17 @@ import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' +const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => { + const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd' + return { USER_ID, getOrCreateAnonymousUserId: vi.fn(() => USER_ID) } +}) + +vi.mock('@deepseek-ai/dsh-user-id', () => ({ + getOrCreateAnonymousUserId, +})) + +beforeEach(() => getOrCreateAnonymousUserId.mockClear()) + interface Harness { readonly ctx: Context readonly agent: Agent @@ -93,7 +104,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}`, + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -141,8 +152,8 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, - { kind: 'success', text: `Feedback recorded for session ${test.session.id}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) @@ -167,6 +178,7 @@ describe('/feedback human command', () => { } await expect(run(test)).resolves.toEqual(expected) await expect(run(test, ' \n\t ')).resolves.toEqual(expected) + expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) const done = test.session.events.filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 98609afdea..958b23736f 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' @@ -11,6 +11,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' let root: string | undefined let context: Context | undefined @@ -20,6 +21,7 @@ afterEach(async () => { context = undefined if (root !== undefined) await rm(root, { recursive: true, force: true }) root = undefined + vi.unstubAllEnvs() }) /** Register one idle agent over a store-owned session, as an app's spine does. */ @@ -51,6 +53,7 @@ function agent(ctx: Context): Agent { describe('/feedback real Loader composition through cordis.yml', () => { it('boots cordis.yml and records feedback without model-visible output', async () => { root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-')) + vi.stubEnv('DSH_HOME', root) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-agent'", @@ -87,9 +90,10 @@ describe('/feedback real Loader composition through cordis.yml', () => { expect(context.commands.list(owner).map(command => command.name)).toContain('feedback') const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal) + const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', - text: 'Feedback recorded for session feedback-loader-agent', + text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`, }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index f59431af9a..c39f55f60f 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/user-id" + }, { "path": "../../support/invariants" } diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 5d941e3fe1..2af0b5294e 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -35,23 +35,21 @@ }, "peerDependencies": { "@deepseek-ai/dsh-command-feedback": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-telemetry": "^0.0.1", + "@deepseek-ai/dsh-user-id": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b128f65978..b66d641750 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -25,7 +25,7 @@ import { type TelemetrySeverity, } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' -import { getOrCreateAnonymousUserId } from './user-id.ts' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' import { BatchLogRecordProcessor, LoggerProvider, diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 6139b5c505..511c95c0d8 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -13,7 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' -import { getOrCreateAnonymousUserId } from '../src/user-id.ts' +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' import Loader from '@cordisjs/plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json index 60aee08eda..421742a62d 100644 --- a/packages/session/session-telemetry-otel/tsconfig.json +++ b/packages/session/session-telemetry-otel/tsconfig.json @@ -30,10 +30,7 @@ "path": "../session-telemetry" }, { - "path": "../../util/brand" - }, - { - "path": "../../util/paths" + "path": "../user-id" }, { "path": "../../support/invariants" diff --git a/packages/session/user-id/README.i18n.yaml b/packages/session/user-id/README.i18n.yaml new file mode 100644 index 0000000000..5d58bba70e --- /dev/null +++ b/packages/session/user-id/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session/user-id/README.md +README.md: 31a72f5e7b58b90b165b16374c2301389cbe2ca0 +README.zh.md: 013097b3038c43ff740660ef9159ca2b13f7b743 diff --git a/packages/session/user-id/README.md b/packages/session/user-id/README.md new file mode 100644 index 0000000000..31a72f5e7b --- /dev/null +++ b/packages/session/user-id/README.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-user-id + +English | [中文](README.zh.md) + +Shared anonymous identity for session telemetry and direct feedback acknowledgement. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement so an operator can correlate a submitted session and user with exported telemetry. + +The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities, and the dsh-sdk launcher telemetry intentionally keeps its own unrelated store. + +## Storage contract + +Reads and writes are synchronous because both boot-time telemetry construction and direct command execution need one API. The result is memoized per resolved file path for the process lifetime. A first writer uses exclusive creation and a concurrent loser adopts the persisted winner; a corrupt file is replaced. Persistence is best-effort, so an unwritable home still receives a process-local UUID rather than blocking telemetry or feedback. + +## Composition + +This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect. + +## Model Experience + +None, as the identifier is used only in telemetry metadata and a direct human command response; it never enters a model request. + +#### KV Cache effect + +None; this package never contributes to a model request. + +## Known Limitations and Deferred Work + +- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity. +- **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value. +- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated, and this package does not unify the separate dsh-sdk launcher telemetry identity. diff --git a/packages/session/user-id/README.zh.md b/packages/session/user-id/README.zh.md new file mode 100644 index 0000000000..013097b303 --- /dev/null +++ b/packages/session/user-id/README.zh.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-user-id + +[English](README.md) | 中文 + +会话遥测与直接反馈确认共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值,以便运维人员将所报告的会话和用户与导出的遥测相关联。 + +该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份,dsh-sdk launcher telemetry 则刻意使用与此无关的独立存储。 + +## 存储契约 + +读写采用同步方式,因为启动时构造遥测和直接执行命令都需要使用同一个 API。结果在进程生命周期内按解析后的文件路径缓存。首个写入方采用独占创建;并发竞争中失败的一方会采用已持久化的胜出值。损坏的文件会被替换。持久化采用 best-effort,因此即使 home 不可写,系统仍会返回进程本地 UUID,而不会阻塞遥测或反馈。 + +## 组合 + +本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。 + +## 模型体验 + +无,因为该标识符只用于遥测元数据和面向用户的直接命令响应;它绝不会进入模型请求。 + +#### KV Cache 影响 + +无;本包绝不会向模型请求贡献任何内容。 + +## 已知限制与暂缓工作 + +- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。 +- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。 +- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联,本包也不会统一 dsh-sdk launcher telemetry 的独立身份。 diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json new file mode 100644 index 0000000000..2a09c73b0e --- /dev/null +++ b/packages/session/user-id/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-user-id", + "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session/session-telemetry-otel/src/user-id.ts b/packages/session/user-id/src/index.ts similarity index 79% rename from packages/session/session-telemetry-otel/src/user-id.ts rename to packages/session/user-id/src/index.ts index 0a2cf95a6a..ca314e945a 100644 --- a/packages/session/session-telemetry-otel/src/user-id.ts +++ b/packages/session/user-id/src/index.ts @@ -1,22 +1,20 @@ /** - * Per-harness-home anonymous user id for the OTel Resource. + * Per-harness-home anonymous user id shared by telemetry and feedback. * * The id is a random UUID persisted as a bare line in `.userid` inside the * harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), * and never derived from the hostname, network address, git remote, or any - * other identifying source — a derived id would make "anonymous" a fiction. - * The id is scoped to the harness home, not the machine: every process - * sharing one `$DSH_HOME` reports the same id, and deleting the file simply - * mints a fresh identity on the next launch (loss is accepted by design). - * This identity belongs to the OTel feed alone; the dsh-sdk launcher - * telemetry keeps its own separate store. + * other identifying source. It is scoped to the harness home, not the + * machine: every process sharing one `$DSH_HOME` reports the same id, and + * deleting the file mints a fresh identity on the next launch. The dsh-sdk + * launcher telemetry keeps its own separate store. * - * Reads and writes are synchronous so the backend constructor can call this - * on its boot path, and the result is memoized per resolved file path: one - * process touches the disk once, and a file deleted mid-run keeps the - * process's id until the next launch. + * Reads and writes are synchronous so boot-time and command consumers can + * use one API. The result is memoized per resolved file path: one process + * touches the disk once, and a file deleted mid-run keeps the process's id + * until the next launch. * - * @module @deepseek-ai/dsh-session-telemetry-otel/user-id + * @module @deepseek-ai/dsh-user-id */ import { randomUUID } from 'node:crypto' @@ -64,8 +62,8 @@ function readPersistedId(file: string): AnonymousUserId | undefined { * narrow create-to-write window can still yield two per-process ids for that * run; the next launch converges on the persisted one.) Persistence is * best-effort — a write failure (read-only home) still returns a usable id - * for the current run so telemetry is never blocked. - * @param options - Home-location and UUID-generation hooks. + * for the current run so feedback and telemetry are never blocked. + * @param options - home-location and UUID-generation seams. * @returns the stable per-harness-home anonymous user id. */ export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId { diff --git a/packages/session/user-id/src/invariant.ts b/packages/session/user-id/src/invariant.ts new file mode 100644 index 0000000000..b649e23619 --- /dev/null +++ b/packages/session/user-id/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-user-id`. + * @module @deepseek-ai/dsh-user-id/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-user-id' + +/** Cordis companion plugin name. */ +export const name = 'user-id-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the API owns one private memo and one best-effort + * file, with no independent event stream or public mutable relation for a + * companion to compare without creating the identity as a side effect. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session/user-id/tests/invariant.spec.ts b/packages/session/user-id/tests/invariant.spec.ts new file mode 100644 index 0000000000..abffc89621 --- /dev/null +++ b/packages/session/user-id/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant' + +describe('invariant companion', () => { + it('registers the package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(UserIdInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/session/session-telemetry-otel/tests/user-id.spec.ts b/packages/session/user-id/tests/user-id.spec.ts similarity index 99% rename from packages/session/session-telemetry-otel/tests/user-id.spec.ts rename to packages/session/user-id/tests/user-id.spec.ts index f7abf45f0b..0f21cb8204 100644 --- a/packages/session/session-telemetry-otel/tests/user-id.spec.ts +++ b/packages/session/user-id/tests/user-id.spec.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { USER_ID_FILE_NAME, getOrCreateAnonymousUserId, -} from '../src/user-id.ts' +} from '../src/index.ts' const dirs: string[] = [] diff --git a/packages/session/user-id/tsconfig.json b/packages/session/user-id/tsconfig.json new file mode 100644 index 0000000000..52e417d5ba --- /dev/null +++ b/packages/session/user-id/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../util/paths" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7de565e0c9..2507f83973 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3686,6 +3686,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-user-id': + specifier: workspace:^ + version: link:../../session/user-id cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5845,9 +5848,6 @@ importers: '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback @@ -5857,15 +5857,15 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-paths': - specifier: workspace:^ - version: link:../../util/paths '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../session-telemetry + '@deepseek-ai/dsh-user-id': + specifier: workspace:^ + version: link:../user-id cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis @@ -5988,6 +5988,21 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/session/user-id: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/settings/settings: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1b39447215..1a1de87d09 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -127,6 +127,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' }, 'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, + 'packages/session/user-id': { kind: 'none', reason: 'The shared identifier appears only in telemetry metadata and a direct human command response; it registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 750b586f5f..d9bf1c29e4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -139,6 +139,7 @@ { "path": "./packages/session/session-title-first-message-llm" }, { "path": "./packages/session/session-title-all-messages-llm" }, { "path": "./packages/session/session-telemetry" }, + { "path": "./packages/session/user-id" }, { "path": "./packages/session/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, From a27efdef363de392d675d4ce517db58b78826b44 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sun, 9 Aug 2026 15:27:21 +0800 Subject: [PATCH 79/81] docs: make technical prose concrete --- .agents/notes/AGENTS.md | 2 +- .agents/notes/README.i18n.yaml | 4 +- .agents/notes/README.md | 12 +-- .agents/notes/README.zh.md | 8 +- .agents/notes/implemented/AGENTS.md | 2 +- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 40 +++++----- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 36 ++++----- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 2 +- ...08-04-configuration-source-ownership.zh.md | 2 +- .../feature/2026-06-15-code-mode.i18n.yaml | 4 +- .../feature/2026-06-15-code-mode.md | 6 +- .../feature/2026-06-15-code-mode.zh.md | 6 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-21-subagent-capability-seam.zh.md | 4 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 2 +- ...16-durable-per-step-time-context.i18n.yaml | 4 +- ...026-07-16-durable-per-step-time-context.md | 2 +- ...-07-16-durable-per-step-time-context.zh.md | 2 +- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-log-spill.md | 20 ++--- .../2026-07-26-code-dispatch-log-spill.zh.md | 20 ++--- ...-plan-review-presentation-intent.i18n.yaml | 4 +- ...6-07-30-plan-review-presentation-intent.md | 6 +- ...7-30-plan-review-presentation-intent.zh.md | 6 +- ...3-translation-prompt-v4-contract.i18n.yaml | 4 +- ...26-07-23-translation-prompt-v4-contract.md | 6 +- ...07-23-translation-prompt-v4-contract.zh.md | 6 +- ...staller-adopts-existing-checkout.i18n.yaml | 4 +- ...7-31-installer-adopts-existing-checkout.md | 10 +-- ...1-installer-adopts-existing-checkout.zh.md | 10 +-- ...08-unified-github-label-taxonomy.i18n.yaml | 4 +- ...026-08-08-unified-github-label-taxonomy.md | 10 +-- ...-08-08-unified-github-label-taxonomy.zh.md | 10 +-- ...-09-chinese-contract-terminology.i18n.yaml | 4 +- ...2026-08-09-chinese-contract-terminology.md | 2 +- ...6-08-09-chinese-contract-terminology.zh.md | 2 +- ...-09-committed-artifact-citations.i18n.yaml | 4 +- ...2026-08-09-committed-artifact-citations.md | 4 +- ...6-08-09-committed-artifact-citations.zh.md | 4 +- ...-names-actors-and-recorded-facts.i18n.yaml | 4 +- ...e-prose-names-actors-and-recorded-facts.md | 4 +- ...rose-names-actors-and-recorded-facts.zh.md | 4 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- .../2026-07-29-shared-base-config-overlays.md | 2 +- ...26-07-29-shared-base-config-overlays.zh.md | 2 +- ...root-and-derived-medium-recovery.i18n.yaml | 4 +- ...torage-root-and-derived-medium-recovery.md | 2 +- ...age-root-and-derived-medium-recovery.zh.md | 2 +- .../skills/dsh-archive-agent-notes/SKILL.md | 12 +-- .agents/skills/dsh-code-review/SKILL.md | 20 ++--- .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 12 +-- .../skills/dsh-find-simplifications/SKILL.md | 26 +++--- .agents/skills/dsh-prose-standard/SKILL.md | 14 ++-- .../dsh-prose-standard/references/examples.md | 16 ++-- .agents/skills/dsh-translate-docs/SKILL.md | 2 +- .agents/skills/dsh-trim-cot-leakage/SKILL.md | 4 +- .../references/examples.md | 2 +- .../references/recall-batteries.md | 6 +- .agents/skills/record-browser-gif/SKILL.md | 8 +- .github/issue-management/policy.mjs | 4 +- AGENTS.md | 12 +-- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- .../agent-presets/code/agent.cordis.yml | 2 +- .../agent-presets/cordis/agent.cordis.yml | 2 +- .../editing-cordis-compositions/SKILL.md | 4 +- .../agent-presets/standard/agent.cordis.yml | 2 +- apps/cli/reference/README.i18n.yaml | 2 +- apps/cli/reference/README.md | 2 +- apps/cli/src/args.ts | 2 +- apps/cli/src/process-shutdown.ts | 4 +- apps/cli/tests/memory-mcp-configs.spec.ts | 4 +- apps/cli/tests/source-launch.compat.spec.ts | 2 +- apps/web/tests/approval-composer.e2e.ts | 6 +- apps/web/tests/chat-long-interactions.e2e.ts | 2 +- apps/web/tests/complex-history.perf.ts | 4 +- apps/web/tests/composer-draft-scroll.e2e.ts | 10 +-- .../tests/details-session-lifecycle.e2e.ts | 2 +- apps/web/tests/navigation-panes.e2e.ts | 4 +- apps/web/tests/pwsh-terminal.e2e.ts | 6 +- apps/web/tests/question-composer.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 6 +- apps/web/tests/search-card.snapshot.ts | 8 +- apps/web/tests/seeded-history.e2e.ts | 4 +- apps/web/tests/shipped-composition.e2e.ts | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 9 +-- apps/web/tests/todo-row.snapshot.ts | 2 +- apps/web/tests/turn-tail-actions.e2e.ts | 4 +- apps/web/tests/workspace-management.e2e.ts | 4 +- apps/web/vite.config.ts | 6 +- docs/AGENTS.md | 18 ++--- docs/agent-lifecycle.i18n.yaml | 4 +- docs/agent-lifecycle.md | 4 +- docs/agent-lifecycle.zh.md | 4 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 6 +- docs/api-gateway.zh.md | 6 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 6 +- docs/architecture.zh.md | 8 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 20 ++--- docs/config-catalog.zh.md | 20 ++--- docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 6 +- docs/cookbook/adding-a-package.zh.md | 6 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 4 +- docs/cookbook/adding-a-tool.zh.md | 4 +- .../adding-a-vendored-package.i18n.yaml | 4 +- docs/cookbook/adding-a-vendored-package.md | 6 +- docs/cookbook/adding-a-vendored-package.zh.md | 6 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 6 +- docs/cookbook/extension-cookbook.zh.md | 6 +- .../maintaining-dsh-code-review.i18n.yaml | 4 +- docs/cookbook/maintaining-dsh-code-review.md | 2 +- .../maintaining-dsh-code-review.zh.md | 2 +- docs/cordis-primer.i18n.yaml | 2 +- docs/cordis-primer.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 2 +- docs/cordis-tutorial/index.zh.md | 2 +- docs/defensive-patterns.i18n.yaml | 4 +- docs/defensive-patterns.md | 4 +- docs/defensive-patterns.zh.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 14 ++-- docs/development.zh.md | 14 ++-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/graph-atlas.i18n.yaml | 4 +- docs/graph-atlas.md | 2 +- docs/graph-atlas.zh.md | 2 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 6 +- docs/i18n/README.zh.md | 6 +- docs/i18n/style-samples.md | 14 ++-- docs/i18n/translation-prompt.md | 16 ++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- ...-acp-default-export-drops-inject.i18n.yaml | 4 +- .../0001-acp-default-export-drops-inject.md | 4 +- ...0001-acp-default-export-drops-inject.zh.md | 4 +- ...ession-disabled-filesystem-tools.i18n.yaml | 4 +- ...js-expression-disabled-filesystem-tools.md | 4 +- ...expression-disabled-filesystem-tools.zh.md | 4 +- ...0003-web-agent-gui-feedback-loop.i18n.yaml | 4 +- .../0003-web-agent-gui-feedback-loop.md | 2 +- .../0003-web-agent-gui-feedback-loop.zh.md | 2 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 10 +-- docs/subsystems/README.zh.md | 10 +-- docs/subsystems/bash.i18n.yaml | 4 +- docs/subsystems/bash.md | 2 +- docs/subsystems/bash.zh.md | 2 +- docs/subsystems/code-runtime.i18n.yaml | 2 +- docs/subsystems/code-runtime.md | 2 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 4 +- docs/subsystems/compaction.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 20 ++--- docs/subsystems/core.zh.md | 22 +++--- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 2 +- docs/subsystems/credentials.zh.md | 2 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 16 ++-- docs/subsystems/filesystem.zh.md | 16 ++-- docs/subsystems/goal.i18n.yaml | 4 +- docs/subsystems/goal.md | 4 +- docs/subsystems/goal.zh.md | 4 +- docs/subsystems/http-server.i18n.yaml | 4 +- docs/subsystems/http-server.md | 8 +- docs/subsystems/http-server.zh.md | 8 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 30 +++---- docs/subsystems/llm-streaming.zh.md | 30 +++---- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 2 +- docs/subsystems/persistence.zh.md | 2 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 43 +++++----- docs/subsystems/plan.zh.md | 43 +++++----- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 6 +- docs/subsystems/session-projection.zh.md | 6 +- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 4 +- docs/subsystems/session-query.zh.md | 4 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 2 +- docs/subsystems/session-reference.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 6 +- docs/subsystems/session.zh.md | 8 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 6 +- docs/subsystems/settings.zh.md | 6 +- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 6 +- docs/subsystems/storage.zh.md | 6 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 9 ++- docs/subsystems/subagent.zh.md | 9 ++- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 2 +- docs/subsystems/system-prompt.zh.md | 2 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 6 +- docs/subsystems/tasks.zh.md | 6 +- docs/subsystems/telemetry.i18n.yaml | 4 +- docs/subsystems/telemetry.md | 17 ++-- docs/subsystems/telemetry.zh.md | 17 ++-- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 27 ++++--- docs/subsystems/tools.zh.md | 27 ++++--- docs/subsystems/user-interaction.i18n.yaml | 4 +- docs/subsystems/user-interaction.md | 10 +-- docs/subsystems/user-interaction.zh.md | 10 +-- docs/subsystems/web.i18n.yaml | 4 +- docs/subsystems/web.md | 4 +- docs/subsystems/web.zh.md | 4 +- docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 10 +-- docs/subsystems/workflow.zh.md | 10 +-- docs/testing.i18n.yaml | 4 +- docs/testing.md | 8 +- docs/testing.zh.md | 8 +- docs/tool-execution-pipeline.i18n.yaml | 4 +- docs/tool-execution-pipeline.md | 2 +- docs/tool-execution-pipeline.zh.md | 2 +- docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 2 +- docs/user/develop/basic/index.zh.md | 2 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 4 +- docs/user/develop/basic/publish.zh.md | 4 +- docs/user/develop/practice/index.i18n.yaml | 4 +- docs/user/develop/practice/index.md | 6 +- docs/user/develop/practice/index.zh.md | 8 +- docs/web-styling.i18n.yaml | 2 +- docs/web-styling.zh.md | 2 +- examples/AGENTS.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 2 +- examples/acp-agent/tests/escalation.e2e.ts | 8 +- .../headless-agent/tests/keyless-smoke.e2e.ts | 2 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 4 +- examples/mcp-memory/README.zh.md | 4 +- examples/web-cordis/README.i18n.yaml | 4 +- examples/web-cordis/README.md | 2 +- examples/web-cordis/README.zh.md | 2 +- native/landlock-run/AGENTS.md | 6 +- native/landlock-run/docs/architecture.md | 2 +- native/landlock-run/docs/packaging.md | 2 +- packages/AGENTS.md | 12 +-- packages/bash/bash-sandbox/src/index.ts | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 6 +- packages/boot/app-boot/README.zh.md | 6 +- packages/boot/app-boot/src/index.ts | 8 +- packages/boot/app-boot/src/profile.ts | 2 +- packages/client/AGENTS.md | 18 ++--- packages/client/hmr/README.i18n.yaml | 2 +- packages/client/hmr/README.md | 2 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- packages/client/modules/src/index.ts | 4 +- packages/client/tsdown.client.ts | 8 +- packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 6 +- packages/client/ui-command/README.zh.md | 6 +- .../client/ui-command/src/client/directory.ts | 2 +- .../src/client/skeleton/InputBar.tsx | 2 +- packages/client/ui-goal/src/client/index.ts | 2 +- .../client/ui-model/src/client/service.ts | 2 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../ui-primitives/src/markdown/highlight.ts | 2 +- .../ui-question/src/client/contract/slots.ts | 4 +- .../client/tool/models/search-card-model.ts | 11 +-- .../tool/toolviews/ask-question-row.tsx | 4 +- .../src/client/tool/toolviews/todo-row.tsx | 2 +- .../code-runtime-worker/src/bootstrap.ts | 2 +- .../context/time-context/src/invariant.ts | 4 +- .../time-context/tests/invariant.spec.ts | 10 +-- packages/core/session/src/index.ts | 4 +- packages/core/system-prompt/src/index.ts | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 6 +- packages/core/tools/README.zh.md | 6 +- packages/core/tools/src/code-mode.ts | 42 +++++----- packages/core/tools/src/index.ts | 13 +-- packages/core/tools/src/json-schema.ts | 4 +- packages/core/tools/src/py-types.ts | 2 +- packages/core/tools/tests/code-mode.spec.ts | 12 +-- .../credentials-local/src/index.ts | 2 +- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 2 +- packages/experimental/AGENTS.md | 4 +- packages/fs/fs-policy/src/types.ts | 12 +-- packages/fs/tool-fs-search/src/grep.ts | 2 +- packages/fs/tool-fs/src/edit.ts | 2 +- packages/fs/tool-fs/src/write.ts | 2 +- packages/goal/goal/src/fold.ts | 10 +-- packages/goal/goal/tests/goal.spec.ts | 10 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 8 +- packages/host/apiproxy/README.zh.md | 10 +-- .../host/apiproxy/src/api/approvals.schema.ts | 2 +- .../host/apiproxy/src/api/commands.schema.ts | 2 +- .../host/apiproxy/src/api/events.schema.ts | 2 +- packages/host/apiproxy/src/api/rpc.schema.ts | 4 +- .../host/apiproxy/src/api/sessions.schema.ts | 2 +- .../directory-picker-auto/README.i18n.yaml | 4 +- packages/host/directory-picker-auto/README.md | 2 +- .../host/directory-picker-auto/README.zh.md | 2 +- .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 4 +- packages/host/directory-picker/README.zh.md | 4 +- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 4 +- packages/host/webserver/README.zh.md | 4 +- packages/host/webserver/src/index.ts | 15 ++-- .../interaction/permission/src/invariant.ts | 2 +- .../user-interaction/README.i18n.yaml | 4 +- .../interaction/user-interaction/README.md | 2 +- .../interaction/user-interaction/README.zh.md | 2 +- .../interaction/user-interaction/src/types.ts | 8 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/catalog.ts | 6 +- packages/llm/llm-pi-ai/src/config.ts | 8 +- packages/llm/llm/src/index.ts | 4 +- packages/llm/llm/src/message.ts | 10 +-- packages/llm/llm/src/types.ts | 8 +- packages/llm/token-meter/src/index.ts | 4 +- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 10 +-- packages/plan/plan-mode/README.zh.md | 12 +-- packages/plan/plan-mode/src/index.ts | 79 ++++++++++--------- packages/plan/plan-mode/src/types.ts | 4 +- .../plan/plan-mode/tests/projection.spec.ts | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 +- packages/sandbox/sandbox-local/README.zh.md | 2 +- packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox/sandbox-policy/src/invariant.ts | 2 +- packages/scaffold/client/src/api.ts | 2 +- .../helper/src/documents/tsconfig-file.ts | 2 +- .../helper/src/features/define-feature.ts | 2 +- .../scaffold/helper/src/features/feature.ts | 2 +- .../repository-plugin/src/index.ts | 2 +- .../tool-cordis/src/api-catalog.ts | 22 +++--- .../tool-cordis/src/sandbox.ts | 4 +- .../session-query/session-query/src/index.ts | 4 +- .../session-persistence-jsonl/src/format.ts | 4 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 8 +- .../session/session-persistence/README.zh.md | 8 +- .../session/session-projection/src/index.ts | 4 +- .../session-telemetry-otel/src/index.ts | 16 ++-- .../session-telemetry/README.i18n.yaml | 4 +- packages/session/session-telemetry/README.md | 4 +- .../session/session-telemetry/README.zh.md | 4 +- .../session/session-telemetry/src/index.ts | 11 ++- packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 2 +- packages/settings/settings/README.zh.md | 2 +- packages/settings/settings/src/index.ts | 24 +++--- .../settings/settings/tests/settings.spec.ts | 10 +-- packages/storage/storage-domain/src/spec.ts | 2 +- packages/storage/storage/src/backend.ts | 12 +-- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/index.ts | 2 +- packages/subagent/subagent-acp/src/run.ts | 4 +- packages/subagent/subagent/src/types.ts | 5 +- packages/support/acp-snapshot/src/suite.ts | 2 +- packages/support/invariants/README.i18n.yaml | 4 +- packages/support/invariants/README.md | 4 +- packages/support/invariants/README.zh.md | 4 +- packages/support/llm-replay/src/index.ts | 4 +- packages/tasks/tasks-local/src/index.ts | 4 +- .../tasks/tasks-local/tests/tasks.spec.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 2 +- packages/typert/loader/src/index.ts | 6 +- packages/typert/loader/tests/loader.spec.ts | 2 +- packages/util/retention/src/index.ts | 6 +- packages/web/tool-web/src/index.ts | 2 +- packages/web/web-fetch-local/src/index.ts | 2 +- packages/web/web-fetch-local/src/provider.ts | 2 +- packages/web/web/src/index.ts | 2 +- packages/web/web/src/types.ts | 2 +- .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/README.zh.md | 2 +- packages/workflow/tool-workflow/src/index.ts | 2 +- .../workflow-workerthread/src/meta.ts | 4 +- .../workflow-workerthread/src/realm.ts | 14 ++-- .../workflow-workerthread/src/runtime.ts | 2 +- packages/workflow/workflow/src/types.ts | 8 +- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- scripts/AGENTS.md | 2 +- scripts/archived-agent-notes.ts | 2 +- scripts/client-bundle-purity.spec.ts | 2 +- scripts/cordis-walk.ts | 2 +- scripts/coverage-exempt.ts | 2 +- scripts/doc-typecheck-paths.ts | 2 +- scripts/doc-typecheck.ts | 2 +- scripts/gen-config-catalog.ts | 12 +-- scripts/gen-cordis-catalog.ts | 16 ++-- scripts/gen-doc-graphs.ts | 18 ++--- scripts/gen-third-party-notices.spec.ts | 4 +- scripts/gen-third-party-notices.ts | 4 +- scripts/gen-translation-brief.ts | 2 +- scripts/lint-rule-fingerprint.spec.ts | 4 +- scripts/package-invariants.ts | 4 +- scripts/run-gates.ts | 4 +- .../request-response.expected.json | 14 ++-- scripts/test-invariants.spec.ts | 2 +- scripts/test-invariants.ts | 2 +- scripts/translation-pairing-git.ts | 2 +- scripts/translation-pairing.ts | 4 +- scripts/translation-prompt.spec.ts | 4 +- scripts/translation-prompt.ts | 4 +- scripts/verify-agent-note-classification.ts | 2 +- scripts/verify-agent-note-format.ts | 2 +- scripts/verify-archived-agent-notes.ts | 2 +- scripts/verify-config-source-ownership.ts | 2 +- scripts/verify-export-jsdoc.ts | 6 +- scripts/verify-package-invariants.ts | 2 +- .../verify-package-readme-model-experience.ts | 4 +- scripts/verify-translation-pairing.ts | 2 +- skills/dsh-upgrade/SKILL.md | 2 +- 459 files changed, 1342 insertions(+), 1329 deletions(-) diff --git a/.agents/notes/AGENTS.md b/.agents/notes/AGENTS.md index e8e1a0ef66..66997859a8 100644 --- a/.agents/notes/AGENTS.md +++ b/.agents/notes/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Agent Notes -Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and verification contracts. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note contract](README.md). +Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and required verification. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note rules](README.md). **Every new Agent Note triggers a supersession check.** Search the active tree for older notes covering the same decision or mechanism, classify any full or partial supersession with [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md), and archive every qualifying implemented triplet in the same PR. Keep partial supersessions active and cross-linked. diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 56eeb36b4d..cd767c17bc 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md -README.md: d3a8943a78238d974d54028e38b773e932429b0e -README.zh.md: 4b3a1ee57ea61a8e8ba4d01cf7c719bbf8440e30 +README.md: ae8e4724d610c97d74910d7dec5c95af69e93281 +README.zh.md: d9989dd1d722185145099b706169dfce19559b52 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index d3a8943a78..ae8e4724d6 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the entry point and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format). +One kind of design doc lives here. An **Agent Note** records a decision or proposal that affects this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file defines where Agent Notes live, when to write one, and [the in-file format](#the-file-format). ## Layout and naming @@ -43,13 +43,13 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat ## When to write one -Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). +Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). -An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. +An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. -A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification contracts. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling. +A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification evidence. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling. ## The file format @@ -122,4 +122,4 @@ Moving a file between lifecycle folders means updating the `Status:` line and re ### Chinese counterparts -A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../../docs/i18n/README.md); the machine-checked header tokens (`# Agent Note: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate owns their consistency. +A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../../docs/i18n/README.md); the machine-checked header tokens (`# Agent Note: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate checks their consistency. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 4b3a1ee57e..d9989dd1d7 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这里存放一类设计文档。**Agent Note** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和约定:Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 +这里存放一类设计文档。**Agent Note** 记录影响本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件规定 Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。 ## 布局与命名 @@ -49,9 +49,9 @@ 更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 -被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证约定和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 +被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 -只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证约定。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。 +只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证证据。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。 <a id="the-file-format"></a> @@ -126,4 +126,4 @@ Status: <status> ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节镜像其英文对侧文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 +`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 diff --git a/.agents/notes/implemented/AGENTS.md b/.agents/notes/implemented/AGENTS.md index b8da5dc8ef..5a22674a96 100644 --- a/.agents/notes/implemented/AGENTS.md +++ b/.agents/notes/implemented/AGENTS.md @@ -10,4 +10,4 @@ When a shipped note is unlikely to guide future work, archive its complete tripl ### This is not a license to rewrite the *decision* -Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md). +Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note rules](../README.md). diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index d47c50cdb7..c7be4bbdf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 705b0df5feb5fedaae4d198aed71758b54586e93 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: b28b08b4b9da058e01af62e610d4e226d794151f +2026-07-19-gui-layering-and-rpc-protocol.md: f9c95176321496e965a95b6358d6feaa8466fe89 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 7d20c5a2662c9036382b30a96bc9973c8f0349bd diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 705b0df5fe..f9c9517632 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -8,12 +8,12 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) ## Problem -We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: +We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities: - One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) -- Launching inside Electron with the same Web technology shape as `dsh web` +- Launching inside Electron with the same Web technologies as `dsh web` -That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. +That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly. At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable. @@ -29,19 +29,19 @@ Directories layer as follows: - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else. - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. -- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. +- `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - - A future Electron shape reuses the same web client packages over an IPC fetch carrier. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. + - A future Electron application reuses the same web client packages over an IPC fetch carrier. ``` -apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) +apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) │ consume ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, - webserver web-shape HTTP carriage client half = src/client/) + webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) harness core packages ──────────────────┘ (types reach the browser via import type) @@ -51,12 +51,12 @@ Direction discipline (every rule auditable from package deps): - `runtime → apiproxy` is one-way; apiproxy depends only on type definitions. - Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`). -- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency. +- `webserver` does not depend on `runtime`: it provides an implementation of the `{ fetch }` interface — "webserver ← runtime" is a runtime injection relationship, not a package dependency. - Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the edge rules). TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)). -On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). +On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is classified by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect). #### Layer roles @@ -64,22 +64,22 @@ On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Nod |---|---|---|---| | Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx | | Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly | -| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | +| Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it | | Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell | | Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy | -| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app | +| Application | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per application (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Applications use dynamic imports so they never load each other; workspace knowledge like dist location stays in the app | #### Naming rule Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the client packages' `/client` subpaths so source-level resolution matches the exports map. -#### How to integrate a new shape (operational checklist) +#### How to integrate a new application (operational checklist) 1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below). -2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. +2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(entry-point plugin)` directly, and wear no fetch. +The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. ## Message protocol @@ -169,7 +169,7 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess ### Frames (server→client, named unions) -Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: +Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE with the same event framing; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row: | frame type | payload | when | |---|---|---| @@ -216,7 +216,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| | `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged | @@ -234,15 +234,15 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation ## Consequences -Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. +Every client consumes one contract: adding a unary method is a five-step mechanical change from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. ## Alternatives considered | Rejected | One-line reason | |---|---| -| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages | +| Packaging by product (a web family, an electron family) | Products share host/client capabilities rather than an application implementation; capability-provider layering means a new application needs zero new packages | | A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable | -| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | +| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Clients require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane | | webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer | | Package names without the group prefix (continuing dsh-<tail>) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package | | Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index b28b08b4b9..7d20c5a266 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -8,11 +8,11 @@ Status: implemented ## Problem -需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品 UI 形态。我们把这些形态统一称为 Client。希望具备以下能力: +需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: - 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留) -- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 +- 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 -那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 +那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一约定事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。 @@ -27,19 +27,19 @@ Status: implemented - **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。 - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 -- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 +- `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` -apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch) +apps/* (applications: apps/web = vite app, apps/cli = bin dispatch) │ consume ▼ packages/host/* packages/client/* apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives runtime assembly / host entity dshClient plugins ×8 (node half = empty apply, - webserver web-shape HTTP carriage client half = src/client/) + webserver Web HTTP carriage client half = src/client/) │ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths ▼ │ (type-only + the client base class) harness core packages ──────────────────┘ (types reach the browser via import type) @@ -54,7 +54,7 @@ harness core packages ──────────────────┘ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 -协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 +协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 #### 分层角色 @@ -62,22 +62,22 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. |---|---|---|---| | 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api | | 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 | -| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | +| 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 | | client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 | | client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy | -| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app | +| 应用 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每个应用一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 各应用使用动态 import,因此不会互相加载;dist 定位等 workspace 知识留在 app | #### 命名规则 `packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且 client 各包的 `/client` 子路径要单列条目,使源码级解析与 exports map 一致。 -#### 怎么接入一个新形态(操作清单) +#### 怎么接入一个新应用(操作清单) 1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。 -2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 +2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不套 fetch。 +现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 @@ -214,7 +214,7 @@ export type ResponseValue<K> = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | @@ -232,15 +232,15 @@ export type ResponseValue<K> = ## Consequences -所有 client 形态消费同一约定:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 +所有 client 使用同一约定:加一个 unary 方法是从单一签名出发的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 ## Alternatives considered | 放弃项 | 一句话理由 | |---|---| -| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 | +| 按产品分包(web 一族、electron 一族) | 产品共享的是 host/client 两侧能力,而不是某个应用实现;能力支持方分层让新应用零新包 | | 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 | -| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | +| 消费型 client 直连 ctx(省 apiproxy 一层) | client 需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 | | webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 | | 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths | | 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 | diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 8979270e36..0b2df8cc51 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: d656d329cbc5b3dfbae2561775ed1afc878cebd0 -2026-08-04-configuration-source-ownership.zh.md: 29ef3b83d18d8836b28e4c151ad44c52542b0a11 +2026-08-04-configuration-source-ownership.md: 8ec750de2efaf148fe44a58b415d7da44b87bdc6 +2026-08-04-configuration-source-ownership.zh.md: 64daa54843d55c6f4bde5a8fdece66dbbe835479 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index d656d329cb..8ec750de2e 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -60,7 +60,7 @@ The line is that these take effect with no user action, before any turn, outside ## Alternatives considered -**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each say why they are shaped that way beat one that describes neither accurately. +**Unify credentials into the non-secret ordering, by who authored each source.** Attempted and abandoned: it reads well, but the settings seam already fixes composition *below* the user section, so "authored by deployment" is not a tier the seam can express — and moving `.credentials.yaml` above the launching environment would take away the one override CI, containers, and a per-run `DEEPSEEK_API_KEY=…` depend on. Two orderings that each explain their precedence beat one that describes neither accurately. **Withhold routing and credentials from the invoking project until it is explicitly trusted.** Rejected as the product's stance: a checkout is trusted by default, with no prompt and no stored trust record. The residual is real and worth naming — cloning a repository that carries a `.env` naming another endpoint or key routes that session through it — and a later project-trust gate is where that gets addressed, not a rule that makes the common case require ceremony. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 29ef3b83d1..64daa54843 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -62,7 +62,7 @@ inherited process environment (read-only, wins) ## Alternatives considered -**按「来源由谁书写」把凭据并入非机密顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「由部署方写入」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说清自身形状成因的顺序,好过一条两边都描述不准的顺序。 +**按「来源由谁书写」把凭据并入非机密顺序。** 尝试过并放弃:它读起来很顺,但 settings seam 已经把 composition 固定在用户 section *之下*,因此「由部署方写入」根本不是该 seam 能表达的一层;而把 `.credentials.yaml` 抬到启动环境之上,会夺走 CI、容器和一次性 `DEEPSEEK_API_KEY=…` 所依赖的那唯一一种覆盖。两条各自说明优先顺序的规则,好过一条两边都描述不准的规则。 **在项目被显式信任之前,不给它路由与凭据能力。** 作为产品立场被否决:checkout 默认可信,不询问,也不存储信任记录。残留风险是真实的、值得写明——克隆一个携带 `.env`、其中指定了另一个 endpoint 或密钥的仓库,会让该会话经由它——处理它的地方是日后的 project trust 门禁,而不是一条让常见情形都要走仪式的规则。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml index a334bdc7d5..f8cf184ea9 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: 5ad7be9506c2356f90050b86d85aa77f602946ce -2026-06-15-code-mode.zh.md: 1902c6ba86b6b8d3fecd06c417347a9c082f67b4 +2026-06-15-code-mode.md: 51c53c56f5755d56f49d8e5166a1ceeef0ffc202 +2026-06-15-code-mode.zh.md: dbf8d409dea152b39d215f5dd636989c3f4fa0bb diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.md index 5ad7be9506..51c53c56f5 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.md @@ -18,7 +18,7 @@ Tool presentation belongs to the registry that owns tool visibility: implementin Three decisions, each elaborated in its own section below: -1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry shapes its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. +1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the Service Definition package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); Consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another Service provider package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. @@ -120,11 +120,11 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **The worker is not a hard security boundary.** Deliberate and documented (§Trust posture): posture equals the existing bash tool, containment exceeds it, gating uses the same approval and sandbox policies. Deployments needing more need a future `isolation: 'container'` backend — tracked as the seam's designed extension, not a TODO on this design. -**`stripTypeScriptTypes` is marked experimental.** It is the same engine (amaro/swc) behind Node's own native `.ts` execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and `amaro`/`sucrase` are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end. +**`stripTypeScriptTypes` is marked experimental.** It is the same engine (amaro/swc) behind Node's own native `.ts` execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite checks position preservation and the required parts of the erasable-only rejection message, the call sits behind one private function, and `amaro`/`sucrase` are direct replacements if the API shifts. The erasable-only subset is a model-facing input restriction, and the error tells the model how to correct the program. **Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the Agent Note makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning. -**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. +**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Package modules separate these responsibilities (`ts-types.ts` and `code-mode.ts` beside `schema.ts`, `json-schema.ts`, and `presentation.ts`), while `ctx.codeRuntime` owns all code-runtime-specific implementation. **Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md index 1902c6ba86..dbf8d409de 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md @@ -18,7 +18,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 三项决策,各自在下方独立小节中展开: -1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头塑造其权威贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 +1. **Code Mode 是 `ToolRegistry`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其权威贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含 Service Definition 包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.md);Consumer = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个 Service provider 包,而非重新设计。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 @@ -120,11 +120,11 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,约束能力强于它,门禁使用相同的审批与沙箱策略。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。 -**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的约定线,错误路径是一个可工作的反馈循环,而非死胡同。 +**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎(amaro/swc)相同,在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件会检查位置保持和可擦除限制拒绝消息中的必需部分;调用位于一个私有函数之后,且 `amaro`/`sucrase` 可在 API 变化时直接替换它。仅可擦除子集是面向模型的输入限制,错误消息会告诉模型如何修正程序。 **SDK 的提示词成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 是每部署的;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 -**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内的模块边界(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 共同约束了这一增长:所有基底相关的内容都在 `ctx.codeRuntime` 之后。 +**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内模块把这些职责分开(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`、`json-schema.ts`、`presentation.ts` 并列),所有 code-runtime 专用实现都由 `ctx.codeRuntime` 提供。 **大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index c68db1d6fd..57e84b91ff 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: 28ecf985db1289ce4f80570583eac6a051cd2c0b -2026-06-21-subagent-capability-seam.zh.md: 5f349ddcacebe66c346976b822c9dcc65bda8d18 +2026-06-21-subagent-capability-seam.md: 2b9ea93c40bd4b3547cfda83c3ad0bd52c047792 +2026-06-21-subagent-capability-seam.zh.md: 8b647fa51511e1c4cac55c3a9ea39b7e059bc381 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index 28ecf985db..2b9ea93c40 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -10,7 +10,7 @@ English | [中文](2026-06-21-subagent-capability-seam.zh.md) The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. -The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports: +**Multiple subagent implementations must coexist at runtime.** A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports: - **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); @@ -66,7 +66,7 @@ Each in-process subagent runs in its **own `Session`** (own id, `parentSession` ## Testing -Registry and tool tests replace only the nondeterministic child boundary with a package-local scripted provider while exercising the real `SubagentService`, lifecycle, task integration, and model-facing tool. Provider and consumer export shapes retain their Loader regression coverage for the failure described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. +Registry and tool tests replace only the nondeterministic child with a package-local scripted provider while exercising the real `SubagentService`, lifecycle, task integration, and model-facing tool. Loader regression tests still cover the provider and consumer exports for the failure described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index 5f349ddcac..8b647fa515 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -10,7 +10,7 @@ Status: implemented harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent 将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。 -决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。传输方式: +**多种 subagent 实现必须在运行时共存。**一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。传输方式: - **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本); - **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例); @@ -66,7 +66,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ## 测试 -注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。提供方与消费方的 export 形状仍保留 Loader 回归覆盖,以防止[事故复盘(postmortem)0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 +注册表与工具测试仅用包内脚本化提供方替换非确定性的子 agent,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。Loader 回归测试仍覆盖提供方与消费方的 export,以防止[事故复盘(postmortem)0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 96e76345cc..c9ace8d3d4 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md -2026-06-30-hook-bridges.md: eecc1fc75c10618481231083f890e0e4b122c1ee -2026-06-30-hook-bridges.zh.md: dcc8f496cb3bc6ea7857ce804a9159cbfcbc3f08 +2026-06-30-hook-bridges.md: 0dce552820186755e9243bc5ce1dd3361e2e739d +2026-06-30-hook-bridges.zh.md: 2df3a47fd6219e4007e2cad8f2ffa9bef9600a87 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index eecc1fc75c..0dce552820 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -8,7 +8,7 @@ English | [中文](2026-06-30-hook-bridges.zh.md) The harness's extension surface is its typed interception points ([the interception extension-points Agent Note](2026-06-30-interception-extension-points.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/pre-step`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-stopping`, `subagent/start`, or `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed extension points, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)). -The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome to a typed Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. +The core rule is: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome to a typed Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. ## Decision diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index dcc8f496cb..2df3a47fd6 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -8,7 +8,7 @@ Status: implemented harness 的扩展面是其类型化拦截点(见[拦截扩展点 Agent Note](2026-06-30-interception-extension-points.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start`、`agent/pre-step`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping`、`subagent/start` 或 `subagent/end`。但用户带着**既有的** Claude Code(CC)和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化扩展点上,构建于共享的协议格式(wire format)库之上(见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。 -贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为类型化 Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 +核心规则是:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent),原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为类型化 Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。 ## 决策 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index d01c01c55a..a67e52a0b4 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md -2026-07-16-durable-per-step-time-context.md: e1a5c65894913ad93f46db8ae45e5ef5ead215f3 -2026-07-16-durable-per-step-time-context.zh.md: 129920a02dc21a91ddc92de3d920ddad24968656 +2026-07-16-durable-per-step-time-context.md: 3305d3644fa3baf7e1522311b98b4eb29d08f631 +2026-07-16-durable-per-step-time-context.zh.md: dd7e63710ae99d1a04bc0e87d49976e28af1dae5 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index e1a5c65894..3305d3644f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -6,7 +6,7 @@ English | [中文](2026-07-16-durable-per-step-time-context.zh.md) ## Problem -A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. +A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need requests to retain the readings used by preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. A process-local refresh cache makes displayed time depend on state that cannot survive resume or be reconstructed from the durable session. Durable interval scheduling can reduce append frequency without introducing that hidden state. diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 129920a02d..dd7e63710a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 +仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中,请求需要保留先前步骤使用的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须将模型实际收到的同一份时间上下文纳入考量。 进程本地刷新缓存会使显示时间依赖于一种既无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率,而不引入这种隐藏状态。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml index 6bb0f7e156..f597828cec 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md -2026-07-26-code-dispatch-log-spill.md: 19d37c7ec2dbe5192e4c7e7ecd26e5b7f1467606 -2026-07-26-code-dispatch-log-spill.zh.md: 8dd270278932011db9658f9e4d5c1a1b6fe707ec +2026-07-26-code-dispatch-log-spill.md: a4b8deee86b1f6102e48e34079a93a74dfcfa288 +2026-07-26-code-dispatch-log-spill.zh.md: 0d89329dd91d74491f3f9af0812bb9b4db9c8793 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md index 19d37c7ec2..a4b8deee86 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -4,28 +4,28 @@ Status: implemented English | [中文](2026-07-26-code-dispatch-log-spill.zh.md) -> Scope: bounding the `tool/code-dispatch` event's content with the existing spill machinery. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) accepted the unbounded log deliberately with this spill integration as the payoff point; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) settled the event pair this shaping hooks into. +> Scope: limiting the `tool/code-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) defines the event pair that this listener processes. ## Problem -Since the full-content dispatch logging landed, a `run_code` program that reads a large file wrote the complete rendered text into the session log — uncapped and outside spill policy, while native results were bounded to `maxInlineBytes` before logging. The asymmetry was backwards: sub-calls (built for bulk data work) were precisely the calls most likely to carry huge results, and the JSONL grew by megabytes per such turn. +After full-content dispatch logging was added, a `run_code` program that reads a large file wrote the complete rendered text into the session log without a limit or spill policy, while native results were limited to `maxInlineBytes` before logging. This treated the most likely large results differently: sub-calls are intended for bulk data work, and each affected turn added megabytes to the JSONL. ## Decision -**A log-shaping waterfall on the registry, and the spill policy as its first listener.** +**A `tools/code-dispatch-log` waterfall on the registry, with spill policy as its first listener.** -- **Extension point**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via the registry's PRIVATE `shapeDispatchLog` invoker, handed to the bridge as a capability closure in `RunCodeBridgeOptions` — the waterfall is the public contract, the invoker never widens the service surface; contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. -- **Policy**: `dsh-spill-policy` registers a second arm on the new extension point sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. -- **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. +- **Extension point**: `tools/code-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/code-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `CodeDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. +- **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. +- **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. ## Alternatives considered -**Bound inside the bridge with a plain cap (no spill).** Rejected: truncation without a locator loses data replay/UIs may need, and re-introduces the "truncated summary" degraded render path the stack removed. +**Apply a plain byte limit inside the bridge without spill storage.** Rejected: truncation without a locator loses data that replay or UIs may need and restores the less informative "truncated summary" rendering that earlier changes removed. -**Spill inside the bridge directly (call `ctx.spillStore` from code-mode.ts).** Rejected: the registry would grow a hard dependency on the spill capability; the waterfall keeps the policy where every other spill decision lives, composable and disable-able (omitted `maxInlineBytes` still means a true no-op). +**Spill inside the bridge directly by calling `ctx.spillStore` from `code-mode.ts`.** Rejected: the registry would require the spill capability. The waterfall keeps this policy with the other spill decisions and allows compositions to omit it; omitting `maxInlineBytes` still makes the listener a no-op. -**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute shapes the PROGRAM-facing result (nested calls deliberately skip it so programs get complete data); the durable copy needs its own decision point after the program has its value. +**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute can change the program-facing result, so nested calls deliberately skip it and programs receive complete data. The durable copy needs a separate listener that runs after the program has its value. ## Consequences -The session log is bounded again for Code Mode turns — the README's Known Limitations entry about uncapped dispatch logging is resolved and now points here. Old logs with oversized dispatch content still replay (the event shape is unchanged; only future appends shrink). The web UI renders spilled sub-call output as the preview + locator text through the identical native path, no special casing. +Code Mode dispatch entries in the session log now have the configured byte limit, and the README's Known Limitations entry about unlimited dispatch logging now points here. Old logs with oversized dispatch content still replay because the event fields are unchanged; only future appends contain less text. The web UI renders spilled sub-call output as preview and locator text through the same path as native results, with no special case. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md index 8dd2702789..0d89329dd9 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -4,28 +4,28 @@ Status: implemented [English](2026-07-26-code-dispatch-log-spill.md) | 中文 -> 范围:用既有的 spill 机制为 `tool/code-dispatch` 事件的内容施加边界。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.md)当初有意接受了不设上限的日志,并以这次 spill 集成为兑现点;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)敲定了本次整形所挂接的事件对。 +> 范围:用既有的 spill 实现限制 `tool/code-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.md)有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)定义了该监听器处理的事件对。 ## 问题 -自携带完整内容的分发日志落地以来,读取大文件的 `run_code` 程序过去会把完整的渲染文本写进会话日志,不设上限、位于 spill 策略之外;而原生结果在记录之前就已被限制在 `maxInlineBytes` 以内。这种不对称的方向完全反了:子调用(本就为批量数据工作而设计)恰恰是最可能携带巨大结果的调用,而每个这样的轮次都会让 JSONL 增长数 MB。 +加入完整内容的分发日志后,读取大文件的 `run_code` 程序会把完整的渲染文本写进会话日志,既没有上限,也不经过 spill 策略;原生结果则会在记录之前限制在 `maxInlineBytes` 以内。两类结果受到不同处理,而为批量数据工作设计的子调用最可能产生巨大结果;每个受影响的轮次都会让 JSONL 增长数 MB。 ## 决策 -**在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** +**在注册表上增设 `tools/code-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **扩展点**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由注册表的私有 `shapeDispatchLog` 调用器——作为能力闭包经 `RunCodeBridgeOptions` 交给桥接层;waterfall 才是公开约定,调用器绝不扩大服务接口。故障被兜住:监听器抛出异常时回退到未整形的内容,并用可处理任意抛出值的错误格式化,确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交通道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 -- **策略**:`dsh-spill-policy` 在新扩展点上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 -- **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 +- **扩展点**:`tools/code-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/code-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`CodeDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会在开放轮次内等待全部任务完成。 +- **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过读取被 spill 原生结果的同一路径读取全文,因此两类结果会渲染出相同的信息。 +- **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 ## 曾考虑的替代方案 -**在桥接层内部用普通上限施加边界(不做 spill)。** 否决:没有定位符的截断会丢失回放与 UI 可能需要的数据,还会重新引入本堆叠 PR(Pull Request)链已经移除的「截断摘要」降级渲染路径。 +**在桥接层内部使用普通字节数上限,不存入 spill。** 否决:没有定位符的截断会丢失回放或 UI 可能需要的数据,还会恢复之前更改已经移除的、信息较少的「截断摘要」渲染。 -**直接在桥接层内做 spill(从 code-mode.ts 调用 `ctx.spillStore`)。** 否决:注册表会因此对 spill 能力产生硬依赖;waterfall 则把策略留在所有其他 spill 决策所在的地方,既可组合也可禁用(省略 `maxInlineBytes` 依然意味着真正的 no-op)。 +**直接在桥接层内做 spill,即从 `code-mode.ts` 调用 `ctx.spillStore`。** 否决:注册表会要求提供 spill 能力。waterfall 把该策略与其他 spill 决策放在一起,并允许组合不加载它;省略 `maxInlineBytes` 时,该监听器仍不执行任何操作。 -**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 整形的是面向程序的那份结果(嵌套调用有意跳过它,好让程序拿到完整数据);持久副本需要一个属于自己的决策点,位于程序取得其值之后。 +**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 可以修改面向程序的结果,因此嵌套调用有意跳过它,让程序取得完整数据。持久化副本需要一个单独的监听器,在程序取得其值之后运行。 ## 后果 -对 Code Mode 轮次而言,会话日志重新有了边界:README 中关于分发日志不设上限的 「已知限制」条目已经解决,现在指向本篇。携带超大分发内容的旧日志仍可回放(事件形状未变;只有今后的追加才会变小)。Web UI 经由与原生完全相同的路径,把被 spill 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。 +会话日志中的 Code Mode 分发条目现在遵守已配置的字节数上限,README 中关于分发日志不设上限的「已知限制」条目现在指向本篇。携带超大分发内容的旧日志仍可回放,因为事件字段没有变化;只有今后的追加包含更少文本。Web UI 经由与原生结果相同的路径,把被 spill 的子调用输出渲染为预览和定位符文本,不需要特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml index 0e4f334485..30111dab4b 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md -2026-07-30-plan-review-presentation-intent.md: f524f7ec67dff3e6d7a6bf59ac204f5c8aae13c6 -2026-07-30-plan-review-presentation-intent.zh.md: 69e0ae00fbc2a98ed45a6d2b711a17f7c947c7e6 +2026-07-30-plan-review-presentation-intent.md: 62e1c24aba7a2901a46da4e6ace9707814472974 +2026-07-30-plan-review-presentation-intent.zh.md: 8415b94cab697e0fa9c4094b938d8ceb99107c7f diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md index f524f7ec67..62e1c24aba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.md @@ -12,15 +12,15 @@ Every one of those affordances is wrong for the surface. Reviewing a plan is one ## Decision -A question may declare a **presentation intent**, and the Web composer renders a declared intent as its own surface. `AskUserQuestionItem` gains `intent?: AskUserQuestionIntent`, a tagged shape whose one member is `{ kind: 'plan-review', approve: string }`; `plan-mode` sets it on the review question, naming `Approve` as the label that approves. +A question may declare a **presentation intent**, and the Web composer renders a declared intent as its own surface. `AskUserQuestionItem` gains `intent?: AskUserQuestionIntent`, a tagged union whose one member is `{ kind: 'plan-review', approve: string }`; `plan-mode` sets it on the review question, naming `Approve` as the label that approves. -An intent shapes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so `exit_plan_mode` reads one answer shape regardless of which surface collected it, and a UI that does not know a tag renders the generic flow with nothing lost but the layout. +An intent changes presentation only. The answer protocol is untouched: a UI honouring the intent answers with the same option labels a generic UI would send, so `exit_plan_mode` reads the same answer fields regardless of which surface collected them, and a UI that does not know a tag renders the generic flow with nothing lost but the layout. `approve` names the affirmative option instead of relying on option order, so no UI infers a verdict from a position. Two assertions an intent makes are beyond the types, and `UserInteractionService.ask()` rejects both as `BAD_INTENT` at the asker: an `approve` naming none of that question's own options — before any UI can answer a choice never offered — and an intent on a question with no `detail`, the thing it declares itself a review of, which would ask the user to approve something invisible. On the wire the intent is a discriminated union, so an unrecognised tag is a rejected frame rather than a silently generic render. `ui-question` renders the intent as `PlanReviewPanel`, in the waiting-approval card language: the amber strip carries `Plan review`, the plan is the scrolling markdown body, and the decision row holds three actions — `Chat about it`, `Refuse`, `Approve`. The question text becomes the card's accessible name rather than a headline, because the buttons already say what the decision is. Approve and Refuse answer with the asker's own option labels and keep the asker's descriptions as tooltips; `Chat about it` cancels the request, which returns the composer so the user can simply say what they want. All copy is bilingual under the existing `question` namespace. -Routing lives inside the single composer entry (`QuestionComposer` chooses the shape) rather than in a second chain registration, and `planReviewOf` claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its `detail`, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable. +Routing lives inside the single composer entry (`QuestionComposer` chooses the presentation) rather than in a second chain registration, and `planReviewOf` claims a request only when the card can send every answer that request allows: one question declaring the intent, the plan as its `detail`, the named approve label offered, and a binary single choice — at most one option besides approve, and not multi-select. A third option or a multi-select batch has answers two buttons cannot express, so the generic flow keeps it, and keeps anything else the card cannot render. "Presentation only" is therefore literal: an intent never costs the user a reachable answer, and the client — downstream of a wire boundary — leaves every request answerable. Dismissal became its own model-facing outcome. `ASK_CANCELLED` previously reached the model as "the user cancelled ask_user_question", naming a tool it never called; `exit_plan_mode` now reports that the user dismissed the review to speak instead and to stay in plan mode and wait. Every other ask failure — an abort from turn cancel or provider teardown, where no user is coming — keeps its own message. diff --git a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md index 69e0ae00fb..8415b94cab 100644 --- a/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-plan-review-presentation-intent.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决定 -一个问题可以声明**呈现意图(presentation intent)**,Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`,一个带标签的形状,目前唯一成员是 `{ kind: 'plan-review', approve: string }`;`plan-mode` 在审阅问题上设置它,并指明 `Approve` 是表示批准的标签。 +一个问题可以声明**呈现意图(presentation intent)**,Web 输入区把已声明的意图渲染为它自己的界面。`AskUserQuestionItem` 新增 `intent?: AskUserQuestionIntent`,这是一个带标签的联合,目前唯一成员是 `{ kind: 'plan-review', approve: string }`;`plan-mode` 在审阅问题上设置它,并指明 `Approve` 是表示批准的标签。 -意图只塑造呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,`exit_plan_mode` 读到的都是同一种回答形状;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。 +意图只改变呈现。回答协议不变:遵循意图的 UI 回答的仍是通用 UI 会发送的那些选项标签,因此无论由哪个界面收集,`exit_plan_mode` 读到的都是同一组回答字段;而不认识某个标签的 UI 渲染通用流程,除布局之外一无所失。 `approve` 指名肯定选项,而不依赖选项顺序,因此没有任何 UI 会从位置推断裁决。意图作出的两项断言超出类型的表达能力,`UserInteractionService.ask()` 都以 `BAD_INTENT` 在提问方一侧拒绝:`approve` 未命中该问题自身的任一选项 —— 早于任何 UI 回答一个从未被提供过的选择;以及意图落在没有 `detail` 的问题上,而 `detail` 正是它自称在审阅的东西,那会让用户去批准一件看不见的事。在协议格式(wire format)上意图是可辨识联合,因此无法识别的标签是被拒绝的帧,而不是静默退回通用渲染。 `ui-question` 把该意图渲染为 `PlanReviewPanel`,沿用等待审批卡片的语言:琥珀色条带写着 `Plan review`,计划是可滚动的 markdown 主体,决定行放三个操作 —— `Chat about it`、`Refuse`、`Approve`。问题文本成为卡片的无障碍名称而非标题,因为按钮已经说明了这次决定是什么。Approve 与 Refuse 用提问方自己的选项标签回答,并把提问方的描述保留为 tooltip;`Chat about it` 取消该请求,从而让输入区归位,用户直接说他想说的话即可。所有文案在既有 `question` 命名空间下双语。 -路由住在单一输入区条目内部(由 `QuestionComposer` 选择形状),而不是第二个链式注册;`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只塑造呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。 +路由住在单一输入区条目内部(由 `QuestionComposer` 选择呈现),而不是第二个链式注册;`planReviewOf` 仅在卡片能够发出该请求允许的每一个答案时才接管:只有一个问题且声明了意图、以 `detail` 承载计划、提供了被指名的批准标签,且是二元单选 —— 除批准外最多一个选项,且非多选。出现第三个选项或多选批次时,其答案是两个按钮无法表达的,通用流程保留它,也保留其他任何卡片渲染不了的请求。因此"只改变呈现"是字面意义上的:意图绝不让用户失去一个可达的答案,而位于协议边界下游的客户端让每个请求都保持可回答。 放弃审阅成为面向模型的独立结果。`ASK_CANCELLED` 以前传到模型的是"the user cancelled ask_user_question",指名了一个它从未调用的工具;现在 `exit_plan_mode` 报告用户放弃审阅是为了改用说话,并要求留在 plan mode 中等待。其余每一种 ask 失败 —— 轮次取消或提供方拆卸导致的中止,那里并没有用户会来 —— 保留它们自己的消息。 diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml index b768fb0abc..1e3db15084 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md -2026-07-23-translation-prompt-v4-contract.md: adeb63e0f4bf03ec0b94a371f596bb84138d81ed -2026-07-23-translation-prompt-v4-contract.zh.md: bd9a18788cbc04f733f52270ec486923c552459e +2026-07-23-translation-prompt-v4-contract.md: 68d6837971a0bbb40fbc5ee093e5911515905ba3 +2026-07-23-translation-prompt-v4-contract.zh.md: d5370f5d6d62a6ca89187d9911fc4dd5c5758b07 diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md index adeb63e0f4..68d6837971 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.md @@ -10,7 +10,7 @@ Automated counterpart generation needs a stable prompt that reproduces the regis ## Decision -The committed [translation prompt](../../../../docs/i18n/translation-prompt.md) is the calibrated pipeline asset. Its renderer injects only the source language, target language, and current [terminology table](../../../../docs/i18n/terminology.md), and rejects unknown, missing, or malformed placeholder syntax before assembling a request. The request assembler retains the source basename outside the model-visible prompt and places each reviewed whole-document pair into one bare-text user/assistant example turn before the real source document. The template may carry model-specific calibration rules, but those rules remain subordinate to the repository's binding pairing, terminology, structure, and emphasis contracts. +The committed [translation prompt](../../../../docs/i18n/translation-prompt.md) is the calibrated pipeline asset. Its renderer injects only the source language, target language, and current [terminology table](../../../../docs/i18n/terminology.md), and rejects unknown, missing, or malformed placeholder syntax before assembling a request. The request assembler retains the source basename outside the model-visible prompt and places each reviewed whole-document pair into one bare-text user/assistant example turn before the real source document. The template may carry model-specific calibration rules, but those rules remain subordinate to the repository's pairing, terminology, structure, and emphasis contracts. The v7 calibration retains that v4 protocol and makes the instruction priority explicit: source meaning and protected structure, then the terminology table, then whole-document gold-pair voice, then general guidance and embedded examples. It directs the model to draft as a native technical author and then compare clause by clause, preserving actors, conditions, negation, modality, lifecycle conditions, direction, result channels, ownership, and quantities. Style guidance cannot invent an actor or vary a terminology-table form, defined concept, or contract verb merely for variety. Unresolved terminology stays unchanged in the translation and is reported only as pending review. @@ -26,7 +26,7 @@ The executable contract lives in [the renderer, request assembler, parser, and r **Inject `translation-rules.md` into every request.** That document governs humans and agents as well as the automated pipeline. Injecting it couples each editorial clarification to model behavior and displaces the manually calibrated prompt constraints; the pipeline instead injects the binding terminology table and verifies its own asset directly. -**Use a strict CDATA XML document.** CDATA provides general XML framing but adds a nested protocol, an additional `]]>` escape, and XML-parser behavior that the three-section contract does not otherwise need. Reserving and escaping six delimiter lines keeps the calibrated response shape while preserving arbitrary Markdown. +**Use a strict CDATA XML document.** CDATA provides general XML framing but adds a nested protocol, an additional `]]>` escape, and XML-parser behavior that the three-section contract does not otherwise need. Reserving and escaping six delimiter lines keeps the calibrated response sections while preserving arbitrary Markdown. **Return only the final translation.** A single body is simpler to parse but discards the explicit correction pass used to catch tone, structure, terminology, and punctuation defects before publication. @@ -36,4 +36,4 @@ The executable contract lives in [the renderer, request assembler, parser, and r ## Consequences -Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. Focused tests pin the retained v4 examples and selected v7 safeguards. The `translation-prompt-v4` snapshot directory names the stable renderer/parser protocol lineage rather than the current calibration revision. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with binding repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior. +Prompt wording is executable behavior and receives code review, a translation-prompt verifier, and a runnable request/response snapshot. Focused tests pin the embedded examples and selected v7 safeguards. The `translation-prompt-v4` snapshot directory names the stable renderer/parser protocol series rather than the current calibration revision. The calibrated asset and the general translation rules can evolve for their different audiences, but review must reject contradictions with repository contracts. The line escape is visible only when source documentation contains a wrapper tag on its own line, and parser tests pin its lossless behavior. diff --git a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md index bd9a18788c..d5370f5d6d 100644 --- a/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-translation-prompt-v4-contract.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md),并在组装请求前拒绝未知、缺失或语法格式错误的占位符。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库中具约束力的配对、术语、结构与强调格式约定。 +提交入库的[翻译提示词](../../../../docs/i18n/translation-prompt.md)是经过校准的流水线资源。其渲染器仅注入源语言、目标语言和当前[术语表](../../../../docs/i18n/terminology.md),并在组装请求前拒绝未知、缺失或语法格式错误的占位符。请求组装器在模型可见的提示词之外保留源文件基本名,并在真正的源文档之前,将每组经评审的整篇文档对编排为一个纯文本 user/assistant 示例轮次。模板可以包含针对特定模型的校准规则,但这些规则必须服从仓库的配对、术语、结构与强调格式约定。 v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含义与受保护结构,再遵循术语表,然后以整篇金标校准语体,最后应用一般指导与内嵌示例。模型先以母语技术作者的方式起草,再逐项对照源文,保留执行主体、条件、否定、情态、生命周期条件、方向、结果通道、所有权和数量。文体指导不得虚构执行主体,也不得仅为丰富措辞而改换术语表词形、已定义概念或约定动词。无法裁定的术语在译文中保持不变,只在评审段标为待人工确认。 @@ -26,7 +26,7 @@ v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含 **在每个请求中注入 `translation-rules.md`。** 该文档既约束人类与 agent,也约束自动翻译流水线。注入它会让编辑规范的每次澄清都与模型行为耦合,并挤占经过人工校准的提示词约束;因此流水线仅注入具约束力的术语表,并直接校验自身资源。 -**使用严格的 CDATA XML 文档。** CDATA 提供通用的 XML 封装,但会引入一层嵌套协议、额外的 `]]>` 转义规则,以及三段式约定原本不需要的 XML 解析器行为。预留并转义六种定界行,既能维持经校准的响应形态,也能保留任意 Markdown 内容不变。 +**使用严格的 CDATA XML 文档。** CDATA 提供通用的 XML 封装,但会引入一层嵌套协议、额外的 `]]>` 转义规则,以及三段式约定原本不需要的 XML 解析器行为。预留并转义六种定界行,既能维持经校准的响应分段,也能保留任意 Markdown 内容不变。 **只返回最终译文。** 单一正文更易解析,却会丢弃显式修正步骤;这个步骤用于在发布前发现语气、结构、术语和标点缺陷。 @@ -36,4 +36,4 @@ v7 校准保留这套 v4 协议,并明确指令优先级:先保持源文含 ## 影响 -提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。聚焦测试固定保留的 v4 示例与选定的 v7 保护规则。`translation-prompt-v4` 快照目录命名的是稳定的渲染器/解析器协议谱系,而不是当前校准修订号。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约束性约定冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。 +提示词措辞属于可执行行为,因此需要经过代码评审、翻译提示词校验器校验及可运行的请求/响应快照验证。聚焦测试固定内嵌示例与选定的 v7 保护规则。`translation-prompt-v4` 快照目录命名的是稳定的渲染器/解析器协议系列,而不是当前校准修订号。经校准的资源与通用翻译规则可以针对各自的受众分别演进,但评审必须拒绝任何与仓库约定冲突的改动。只有当源文档中的封装标签独占一行时,行转义才会显现;解析器测试锁定这一无损行为。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 72574c7cf5..1c58887b2b 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 531b780974e1490b27d8b96734ccfadae66c2fc2 -2026-07-31-installer-adopts-existing-checkout.zh.md: 405b58d8d53b3f0895cc7746c957582455667cda +2026-07-31-installer-adopts-existing-checkout.md: 1d45a14273095610e3ed8047da2ce9f4ca95cbb6 +2026-07-31-installer-adopts-existing-checkout.zh.md: 971bc3b389c341b314872b8e45ab20ebd2ed5b2c diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index 531b780974..1d45a14273 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -6,9 +6,9 @@ English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) ## Problem -`scripts/install.sh` produced two incompatible install shapes. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/<timestamp>`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). +`scripts/install.sh` produced two incompatible installation layouts. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/<timestamp>`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). -The direct link is a terminal state. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this shape as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. +The direct link cannot be upgraded. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this layout as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. ## Decision @@ -28,9 +28,9 @@ Before `current` is repointed, the installer rejects a staging path that resolve **Make `~/.dsh/source/master` a symlink to the arbitrary clone.** Rejected. Git resolves the symlink and records the *real* path: a worktree created through it stores `gitdir: …/<clone>/.git/worktrees/<name>`, and `git worktree list` reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves `master` present but dangling and every staging worktree dead with `fatal: not a git repository`. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. `~/.dsh/source/master` is a location, not a name, and only the location is authoritative. -**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to be a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. +**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to point to a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. -**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The divergent shape was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must reason about — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a shape nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains the escape hatch for installing a separate tree. +**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The second layout was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must handle — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a layout nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains available for installing a separate tree. **Warn or prompt when the tree is dirty.** Rejected: `worktree add` from `HEAD` cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead. @@ -38,7 +38,7 @@ Before `current` is repointed, the installer rejects a staging path that resolve ## Consequences -One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable shape. In-repo runs still never mutate the working tree. +One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable layout. In-repo runs still never mutate the working tree. The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 405b58d8d5..971bc3b389 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -6,9 +6,9 @@ Status: implemented ## Problem -`scripts/install.sh`会产生两种互不兼容的安装形态。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 +`scripts/install.sh`会产生两种互不兼容的安装布局。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 -这种直接链接是一种终态。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种形态描述为需要一次性迁移的旧式安装,于是两种布局在安装时就已分叉,并且要到很久以后才会被调和——甚至永远不会。 +这种直接链接无法升级。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种布局描述为需要一次性迁移的旧式安装,因此两种布局从安装时起便不相同,只有以后执行迁移才会一致,而迁移也可能永远不执行。 ## Decision @@ -28,9 +28,9 @@ Status: implemented **把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。 -**把检出自身提升为`current`的目标。** 已否决:升级约定要求`current`必须是位于 staging 分支上的干净 staging worktree,绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 +**把检出自身提升为`current`的目标。** 已否决:升级约定要求`current`指向 staging 分支上的干净 staging worktree,绝不能指向 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 -**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。分叉的形态本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动需要推敲的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的形态而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍是安装另一棵树的退路。 +**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。第二种布局本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动必须处理的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的布局而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍可用于安装另一棵树。 **在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该约定。 @@ -38,7 +38,7 @@ Status: implemented ## Consequences -现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的形态。检出内运行仍然绝不改动工作树。 +现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的布局。检出内运行仍然绝不改动工作树。 代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml index 295dcfd0bf..1c22b0d7f6 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md -2026-08-08-unified-github-label-taxonomy.md: 44508b4f0e2dc4de97950b0738829c0b362cc506 -2026-08-08-unified-github-label-taxonomy.zh.md: b1e3629fd93dcbff6e253058b4c465dc9bb5fd63 +2026-08-08-unified-github-label-taxonomy.md: 8005629861f306d20293af6b348f19bf79cbe1c3 +2026-08-08-unified-github-label-taxonomy.zh.md: 855a2b98f44d517abe1f7718ae4e81262cb031b6 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md index 44508b4f0e..8005629861 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md @@ -27,18 +27,18 @@ The kind set is closed and mutually exclusive: | `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | | `kind/dependency` | Updates dependencies without another dominant intent. | -The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. A new kind changes this classification contract and requires an explicit taxonomy and policy change. +The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. A new kind changes these classification rules and requires an explicit taxonomy and policy change. Repository policy rejects unsupported `kind/*` values and reserves every alias removed by the unification: `kind/bug`, `kind/documentation`, `feature`, `bug-fix`, `doc`, `cleanup`, `testing`, `dependencies`, `ci`, `cli`, `llm`, and `web-search`. Reserving the exact migrated set prevents an obsolete synonym from being recreated as an apparently unrelated operational label. ### Areas -Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory; this record owns the selection rule and the non-obvious boundaries that cannot fit reliably in short label descriptions. +Areas name durable product or engineering subjects rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct behavior or APIs, but it does not combine an umbrella and a narrower label for the same change. GitHub's live `area/*` names and descriptions own the current inventory; this record defines selection cases that cannot fit reliably in short label descriptions. - `area/web` covers browser and Electron graphical interfaces, `area/vscode` covers the editor extension, and `area/api` covers cross-interface protocols and language SDKs. - `area/planning` covers goals, plans, todos, and scheduling, while `area/workflow` covers executable workflows and background task runtimes. - `area/artifact` deliberately combines artifacts, attachments, and multimodal delivery. Split labels become justified only when those concerns again need independent review or queries. -- `area/tools` applies to generic registry, schema, and execution contracts. A concrete capability uses its own area unless it also changes that generic contract. +- `area/tools` applies to generic registry, schema, and execution contracts. A concrete capability uses its own area unless it also changes one of those contracts. - `area/hooks` means the Claude Code and Codex bridges, `area/infra` covers build, release, CI, repository gates, generators, dependencies, and developer tooling, and `area/windows` covers native Windows product support rather than CI runner selection. The area set is intentionally extensible. When no existing description honestly covers a durable and reusable domain, an agent may create a concise `area/<lowercase-kebab-case>` label without separate approval. It must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and it reports the new label and rationale to the requester after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. @@ -61,11 +61,11 @@ Label migrations preserve meaning before removing aliases: add the canonical rep **Separate labels for every delivery shell or media lifecycle.** Browser and Electron delivery share one graphical domain, and artifact, attachment, and multimodal delivery currently share one review/query domain. A split belongs in a later taxonomy change only when it restores useful independent classification. -**Broad implementation labels in place of semantic domains.** A concrete capability is not merely its tool, interface, filesystem, or process implementation. Generic implementation areas apply only when their own contracts change. +**Broad implementation labels in place of product or engineering subjects.** A concrete capability is not merely its tool, interface, filesystem, or process implementation. Generic implementation areas apply only when their own behavior or API changes. **Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift. -**Exactly one area per pull request.** Coherent changes can materially affect several independent contracts, and dropping secondary areas hides affected scope. +**Exactly one area per pull request.** Coherent changes can materially affect several independent APIs or behaviors, and dropping secondary areas hides affected scope. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md index b1e3629fd9..855a2b98f4 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md @@ -27,18 +27,18 @@ Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对 | `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | | `kind/dependency` | 在没有其他主导意图时更新依赖。 | -类型记录主导意图。配套测试、文档、清理或依赖调整不会盖过功能变更或缺陷修复这一主导意图。新增类型会改变这项分类约定,因此必须明确修改分类体系和政策。 +类型记录主导意图。配套测试、文档、清理或依赖调整不会盖过功能变更或缺陷修复这一主导意图。新增类型会改变这些分类规则,因此必须明确修改分类体系和政策。 仓库政策会拒绝不支持的 `kind/*` 值,并将统一过程中移除的所有别名列为保留名称:`kind/bug`、`kind/documentation`、`feature`、`bug-fix`、`doc`、`cleanup`、`testing`、`dependencies`、`ci`、`cli`、`llm` 和 `web-search`。精确保留这组已迁移的名称,可以防止过时的同义名称被重新创建成看似无关的管理用途标签。 ### 领域 -领域表示持久的语义领域,而不是临时专项、归属关系或偶然触及的每条路径。一项 PR 修改不同约定时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项约定。GitHub 上现行的 `area/*` 名称和说明定义当前清单;本记录定义选择规则,以及简短标签说明无法可靠容纳的非显然边界。 +领域表示持久的产品或工程主题,而不是临时专项、归属关系或偶然触及的每条路径。一项 PR 修改不同的行为或 API 时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项变更。GitHub 上现行的 `area/*` 名称和说明定义当前清单;本记录定义简短标签说明无法可靠容纳的选择情形。 - `area/web` 覆盖浏览器与 Electron 图形界面,`area/vscode` 覆盖编辑器扩展,`area/api` 覆盖跨界面协议与各语言 SDK。 - `area/planning` 覆盖目标、计划、待办和调度,`area/workflow` 则覆盖可执行工作流与后台任务运行时。 - `area/artifact` 有意合并产物、附件与多模态交付。只有当这些关注点再次需要独立评审或查询时,才有理由拆分标签。 -- `area/tools` 适用于通用注册表、schema 与执行约定。具体能力使用自身的领域标签,除非它还修改了这项通用约定。 +- `area/tools` 适用于通用注册表、schema 与执行约定。具体能力使用自身的领域标签,除非它还修改了其中一项约定。 - `area/hooks` 表示 Claude Code 与 Codex 桥接,`area/infra` 覆盖构建、发布、CI、仓库门禁、生成器、依赖与开发者工具,`area/windows` 覆盖原生 Windows 产品支持,而不是 CI runner 的选型。 领域集合有意保持可扩展。当现有说明都无法如实涵盖一个持久且可复用的领域时,agent(智能体)无需另行批准,即可创建一个简洁的 `area/<lowercase-kebab-case>` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后向请求者报告该标签及理由。仅为避免新增一个确有必要的领域标签而复用不准确的领域,不可接受。 @@ -61,11 +61,11 @@ Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然 **为每种交付载体或媒体生命周期单设标签。** 浏览器与 Electron 交付共用一个图形界面领域,产物、附件与多模态交付目前也共用一个评审/查询领域。只有当拆分能恢复有用的独立分类时,才应在后续分类体系变更中进行。 -**用宽泛的实现标签取代语义领域。** 一项具体能力并不只是其工具、接口、文件系统或进程实现。通用实现领域只在其自身约定变化时适用。 +**用宽泛的实现标签取代产品或工程主题。** 一项具体能力并不只是其工具、接口、文件系统或进程实现。通用实现领域只在其自身行为或 API 变化时适用。 **在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。 -**每个 PR 恰好一个领域。** 内聚的变更可能对多个独立约定产生实质影响,丢弃次要领域会隐藏受影响范围。 +**每个 PR 恰好一个领域。** 内聚的变更可能对多个独立 API 或行为产生实质影响,丢弃次要领域会隐藏受影响范围。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml index 408f164e06..9997e7252d 100644 --- a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md -2026-08-09-chinese-contract-terminology.md: d2fea45c6864a19b89c78a52f614ccac341a0127 -2026-08-09-chinese-contract-terminology.zh.md: 772b78daba00c5d881fd0091951557dfc0951ee9 +2026-08-09-chinese-contract-terminology.md: fa9e3ab91133a995a63f817816c01b59d28f66e8 +2026-08-09-chinese-contract-terminology.zh.md: 299e9ac9cdb2b8166d2364723b92f878b15cfd66 diff --git a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md index d2fea45c68..fa9e3ab911 100644 --- a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md +++ b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.md @@ -14,7 +14,7 @@ English `convention` also commonly renders as `约定`. That overlap is intentio The terminology source of truth defines `contract` as `约定` and `adapter contract` as `适配器约定(adapter contract)` on first mention. Every active Chinese documentation pair follows that ruling; archived Agent Notes remain frozen. Unpaired bilingual calibration assets and the translation prompt's explanatory prose follow the same terms so they cannot teach the superseded rendering. -The migration is semantic prose maintenance, not a rename of identifiers. Inline code, file paths, links, API names, English filenames containing `contract`, and machine-readable values remain unchanged. `convention` does not receive a global terminology row or corpus-wide rewrite: translators preserve natural Chinese and explicitly disambiguate only where the source contrasts the two concepts. +The migration is semantic prose maintenance, not a rename of identifiers. Inline code, file paths, links, API names, English filenames containing `contract`, and machine-readable values remain unchanged. `convention` does not receive a global terminology row or corpus-wide rewrite: translators preserve natural Chinese and explicitly disambiguate only where the source contrasts the two concepts. The [concrete prose decision](2026-08-09-concrete-prose-names-actors-and-recorded-facts.md) separately decides when English prose should replace a vague `contract` use with the exact rule, API, or behavior before translation. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md index 772b78daba..299e9ac9cd 100644 --- a/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-chinese-contract-terminology.zh.md @@ -14,7 +14,7 @@ Status: implemented 术语真源规定 `contract` 译为「约定」,`adapter contract` 首次出现时写作「适配器约定(adapter contract)」。所有活跃中文文档配对均遵循该裁决;归档 Agent Note 保持冻结。未参与配对的双语校准资产和翻译提示词说明文字也采用相同术语,避免继续教授已被取代的译法。 -这次迁移只维护语义正文,不重命名标识符。行内代码、文件路径、链接、API 名称、文件名中包含的英文 `contract` 以及机器可读值均保持不变。`convention` 不新增全局术语行,也不做全语料改写:翻译时保留自然中文,只在源文明确对比两个概念时消歧。 +这次迁移只维护语义正文,不重命名标识符。行内代码、文件路径、链接、API 名称、文件名中包含的英文 `contract` 以及机器可读值均保持不变。`convention` 不新增全局术语行,也不做全语料改写:翻译时保留自然中文,只在源文明确对比两个概念时消歧。[具体行文决策](2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)另行规定:如果英文正文中的 `contract` 含糊不清,应在翻译前将其改为确切的规则、API 或行为。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml index 0813498ae6..4c9b955615 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md -2026-08-09-committed-artifact-citations.md: a578b1e32268af2669ee26c59d19c738bc9f707b -2026-08-09-committed-artifact-citations.zh.md: d525dc0f58884bdf808b597213a7e23c1d6a0301 +2026-08-09-committed-artifact-citations.md: 044f7683d51ebf2038f56d2b5a27755ecc9be6d5 +2026-08-09-committed-artifact-citations.zh.md: 7f194aef45710e2f24e462c99877ec2e112f9981 diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md index a578b1e322..044f7683d5 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md @@ -18,12 +18,12 @@ Durable prose — comments, JSDoc, docs, notes, test comments and titles — cit - Implemented notes state shipped reality: a "deferred to a later PR" claim whose target shipped names the shipped note instead. - Recorded fixtures, snapshots, and archived notes are exempt: recorded model output and sealed history keep their original voice. Inside a note's change-story sections, a historical stage name ("the first cut shipped X") is current-state-safe; indexical stamps ("this cut") stay banned everywhere. -One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records. The [dsh-trim-cot-leakage skill](../../../skills/dsh-trim-cot-leakage/SKILL.md) operationalizes these rules: the audit taxonomy, the committed recall batteries, and few-shot calibration for the keep/delete boundary. +One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records. The [dsh-trim-cot-leakage skill](../../../skills/dsh-trim-cot-leakage/SKILL.md) operationalizes these rules: the audit taxonomy, the committed recall batteries, and few-shot examples for deciding what to keep or delete. ## Alternatives considered - **Commit the design ledgers and audit documents so the ordinals resolve.** Rejected: session transcripts are working artifacts, not maintained references; committing them would create a parallel, ungated decision corpus beside Agent Notes, and their internal numbering would still drift. -- **A mechanical gate for the banned vocabulary.** Deferred: the vocabulary is unbounded natural language, and the audit's recall batteries need judgment to separate leakage from legitimate prose ("wait" the noun, contrastive "actually", runtime old/new states). A narrow high-precision gate (for example `\(decision \d`, `\(audit [A-Z]\d`, `\bcut \d`, `this cut`, a bare `\bT\d\b`, `P-I`, `used to `, a bare `\bv1\b`, and `§\d` — the last excluding citations whose section numbering has a committed owner, such as web-styling.md's own §N) is the candidate if the pattern recurs; review of the purge itself caught residuals in exactly these post-battery shapes, so they lead the candidate list. +- **A mechanical gate for the banned vocabulary.** Deferred: the vocabulary is unbounded natural language, and the audit's recall batteries need judgment to separate leakage from legitimate prose ("wait" the noun, contrastive "actually", runtime old/new states). A narrow high-precision gate (for example `\(decision \d`, `\(audit [A-Z]\d`, `\bcut \d`, `this cut`, a bare `\bT\d\b`, `P-I`, `used to `, a bare `\bv1\b`, and `§\d` — the last excluding citations whose section numbering has a committed owner, such as web-styling.md's own §N) is the candidate if the pattern recurs; review of the purge itself caught residuals in exactly the cases those searches missed, so they lead the candidate list. - **Delete the rationale that cited dead artifacts.** Rejected: the factual clauses were preserved or restated; only citations, review choreography, and derivation transcripts were removed, per the prose standard's complete-proposition rule. ## Verification diff --git a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md index d525dc0f58..7f194aef45 100644 --- a/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-committed-artifact-citations.zh.md @@ -18,12 +18,12 @@ Status: implemented - 已实现的 Agent Note 陈述已交付的现实:「推迟到后续 PR」的说法若其目标已经交付,就改为点名那篇已交付的 note。 - 已录制的 fixture(测试前置数据)、快照与已归档的 Agent Note 不受此约束:已录制的模型输出与封存的历史保持原有行文。在 note 的变更故事段落内,历史阶段名称(「首版交付了 X」)属于安全的现状表述;指示性切次戳("this cut")在任何地方都仍被禁止。 -一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts`、`scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语,改后重新生成)、type-equiv 源码 JSDoc(改后把文档页重新粘贴)以及双语对侧文件(改后重新记录配对)。[dsh-trim-cot-leakage 技能](../../../skills/dsh-trim-cot-leakage/SKILL.md)把这些规则落地为可执行工作流:审计分类法、已提交的成批召回检索,以及校准保留/删除边界的少样本示例。 +一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts`、`scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语,改后重新生成)、type-equiv 源码 JSDoc(改后把文档页重新粘贴)以及双语对侧文件(改后重新记录配对)。[dsh-trim-cot-leakage 技能](../../../skills/dsh-trim-cot-leakage/SKILL.md)把这些规则落地为可执行工作流:审计分类法、已提交的成批召回检索,以及用于判断保留或删除内容的少样本示例。 ## 曾考虑的替代方案 - **把设计台账与审计文档提交入库,让序号得以解析。**不予采纳:会话 transcript 是工作产物,不是持续维护的参考资料;提交它们会在 Agent Note 之外形成一套平行且不受门禁约束的决策语料,其内部编号也仍会漂移。 -- **为被禁词汇建一道机械门禁。**暂缓:这类词汇是无界的自然语言,审计中以查全为目标的成批检索需要人工判断,才能把泄漏与正当行文区分开(作名词的「wait」、表转折的「actually」、运行时的新旧状态)。若该模式再次出现,候选方案是一道窄而高查准的门禁(例如 `\(decision \d`、`\(audit [A-Z]\d`、`\bcut \d`、`this cut`、裸 `\bT\d\b`、`P-I`、`used to `、裸 `\bv1\b` 与 `§\d`——最后一种需排除章节编号有已提交归属的引用,如 web-styling.md 自身的 §N);对本次清扫自身的评审恰好在这些电池之外的形态中发现残留,因此它们位居候选清单之首。 +- **为被禁词汇建一道机械门禁。**暂缓:这类词汇是无界的自然语言,审计中以查全为目标的成批检索需要人工判断,才能把泄漏与正当行文区分开(作名词的「wait」、表转折的「actually」、运行时的新旧状态)。若该模式再次出现,候选方案是一道窄而高查准的门禁(例如 `\(decision \d`、`\(audit [A-Z]\d`、`\bcut \d`、`this cut`、裸 `\bT\d\b`、`P-I`、`used to `、裸 `\bv1\b` 与 `§\d`——最后一种需排除章节编号有已提交归属的引用,如 web-styling.md 自身的 §N);对本次清扫自身的评审恰好在这些检索未覆盖的案例中发现残留,因此它们位居候选清单之首。 - **删除引用了失效产物的设计理由。**不予采纳:事实性语句都得到保留或改写;依行文标准的完整命题规则,删掉的只有引用、评审编排与推导过程记录。 ## 验证 diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml index b18d088e03..8f9e929996 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md -2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: efb6e9bcc6f1c817bb0be07ccd31bafc4b2ac3fb -2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 41b0ce82631536c53ea45d60c1405a8387885d0f +2026-08-09-concrete-prose-names-actors-and-recorded-facts.md: b7df5403ea11ff8ba32be0f9bede5a32d5bcd6ee +2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md: 51a6c42dc75ad04f7d35ef90dd0d54046585dd6d diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md index efb6e9bcc6..b7df5403ea 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md @@ -18,11 +18,13 @@ The rule applies to Markdown, READMEs, active Agent Notes, JSDoc and comments, p Exact code identifiers, public APIs, durable fields, protocol members, type names, headings with external references, and filenames stay unchanged unless a coordinated contract rename is independently required. Surrounding prose explains their fields or behavior directly. Generated documents and catalogs update from their owning source. +Before using `contract`, `boundary`, or `shape`, writers check whether the sentence means a more specific rule, operation, data structure, field set, validation point, timing point, API, type, or failure condition. `Contract` remains correct for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. `Boundary` remains correct for a literal security, trust, wire, process, serialization, transaction, or lifecycle division. `Shape` remains correct when the structural form itself is the subject and no narrower term such as fields, schema, type, union variant, file layout, or export form states the fact. Code and API names containing these words remain unchanged unless a separate coordinated rename is required. + This decision complements the [documentation tiers and budgets](2026-07-04-doc-tiers-and-budgets.md) decision, which continues to own placement, document form, and word budgets. ## Alternatives considered -**Ban a fixed list of words.** Rejected because a word may be an exact identifier or the clearest term in another contract. Sentence-level review catches ambiguity without rejecting valid names. +**Ban a fixed list of words.** Rejected because a word may be an exact identifier or the clearest term in another contract. For example, caller/callee invariants are real contracts, and process or wire boundaries identify real divisions. Sentence-level review catches ambiguity without rejecting valid names. **Replace every abstract label with “source,” “origin,” or “metadata.”** Rejected because another broad label still leaves readers to infer whether the sentence means a file, caller, event seq, provider/model pair, commit, or build job. diff --git a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md index 41b0ce8263..51a6c42dc7 100644 --- a/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md +++ b/.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.zh.md @@ -18,11 +18,13 @@ Status: implemented 除非另一项独立需求明确要求协调重命名约定,否则确切的代码标识符、公开 API、持久字段、协议成员、类型名、带有外部引用的标题和文件名均保持不变。它们周围的行文直接说明其字段或行为。生成的文档和目录在维护它们的源文件修改后更新。 +使用 `contract`、`boundary` 或 `shape` 之前,写作者要确认句子是否实际指更具体的规则、操作、数据结构、字段集合、校验点、时间点、API、类型或失败条件。调用方、被调用方、实现方、提供方、生产方或消费方依赖的前置条件、后置条件、不变量、兼容性承诺及其他义务仍可准确称为 `contract`。真实的安全、信任、wire、进程、序列化、事务或生命周期分界仍可准确称为 `boundary`。当结构形式本身就是主题,且字段、schema、类型、联合变体、文件布局或导出形式等更窄的词无法说明事实时,仍可使用 `shape`。除非另一项独立需求要求协调重命名,否则包含这些词的代码和 API 名称保持不变。 + 该决策补充了[文档层级与字数预算](2026-07-04-doc-tiers-and-budgets.md)决策;后者继续规定内容位置、文档形式和字数预算。 ## 曾考虑的替代方案 -**禁止一份固定词表中的所有词。** 不予采纳:某个词可能是确切的标识符,也可能是另一项约定中最清楚的用词。逐句审查可以找出歧义,且不会拒绝有效名称。 +**禁止一份固定词表中的所有词。** 不予采纳:某个词可能是确切的标识符,也可能是另一项约定中最清楚的用词。例如,调用方与被调用方依赖的不变量属于真实 contract,进程或 wire boundary 也表示真实分界。逐句审查可以找出歧义,且不会拒绝有效名称。 **将每个抽象名称都替换为“来源”、“起源”或“元数据”。** 不予采纳:另一个宽泛名称仍会让读者自行推测句子指的是文件、调用方、事件 seq、提供方/模型组合、commit 还是构建任务。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index 7c4e1fac3d..6ab79c8447 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: b2ca5cc8d3a160dd09550827d5f8148c14048ea4 -2026-07-29-shared-base-config-overlays.zh.md: adec7c793839e84edb14173d71735d264cf2ec28 +2026-07-29-shared-base-config-overlays.md: bbffcda725e3e2c5c0d0ce56a4ee0f1f61559806 +2026-07-29-shared-base-config-overlays.zh.md: e883ee2c5fd5fc887690e85653a9fc07408b6c6f diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index b2ca5cc8d3..bbffcda725 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -22,7 +22,7 @@ Precedence is list order, last write winning per row: base, then the surface ove `--config <path>` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace <path>` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. -A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. +A patch replaces its target row's whole `config` rather than merging. Therefore, a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. `examples/tui-agent`, `examples/cordis-agent`, `examples/code-mode`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/self-modification/tool-cordis/tests/`, and the supported Code Mode demo remains the ACP overlay at `examples/acp-agent/code-mode.cordis.yml`. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index adec7c7938..e883ee2c5f 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -22,7 +22,7 @@ Status: implemented `--config <path>` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的提供方与 model。`--config-replace <path>` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则恢复时会静默更换 agent(智能体)。 -patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 +patch 会整体替换目标配置项的 `config` 而不合并。因此,取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 `examples/tui-agent`、`examples/cordis-agent`、`examples/code-mode` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/self-modification/tool-cordis/tests/`,受支持的 Code Mode demo 则保留为 `examples/acp-agent/code-mode.cordis.yml` 中的 ACP(Agent Client Protocol)overlay。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index 8fd7d1dd91..b062440270 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: 45e8b3dfcad0590a26581e20c7bc6bb5692e5c36 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 330473b2b17669e373a6dc433a74d8bb3ba37628 +2026-07-28-storage-root-and-derived-medium-recovery.md: 9463af00e2a2f5e2d3cee4cc7d386173007abf45 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 215e43fac6bb52e1ecaf15d6d9d1ce1b5f913bc3 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index 45e8b3dfca..9463af00e2 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -35,7 +35,7 @@ Two independent changes, one per gap. **Launcher patch + a `storageRoot` profile key** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer). -**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single. +**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user chose to place the cache beside `workspace.json` — one hub root keeps the media co-located and the mental model single. **Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index 330473b2b1..215e43fac6 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -35,7 +35,7 @@ Status: proposed **launcher patch + `storageRoot` profile 键**——未采:一行 yml `!!js` 表达式即达全局根,与会话根的既有分层完全一致;launcher patch 多引入一个改写点,profile 键在有真实消费者前是空席(按行覆盖已有个人 config.yaml patch 层可用)。 -**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。 +**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且用户选择把缓存放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。 **缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index e4df7e1fe7..ef20de8097 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest contract. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. --- # Archive DeepSeek Harness Agent Notes @@ -9,18 +9,18 @@ Reduce the active decision corpus without erasing history that can still guide w ## Read the contracts -Read [the Agent Note contract](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. +Read [the Agent Note rules](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. ## Check supersession when adding a note -Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note contract's consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. +Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. ## Classify by future value Apply these lifecycle-specific outcomes: - **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Length does not matter. -- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current contract is obvious elsewhere. +- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current behavior is obvious elsewhere. - **Proposed — never archive:** keep a live proposal active; if it is no longer worth pursuing, reject it with an honest reason and satisfy the rejected lifecycle format. - **Rejected — keep only as a guardrail:** retain a rejection only when the losing proposal remains a tempting, meaningful mistake and the note explains why it loses. - **Rejected — delete:** delete the whole triplet when the rejected idea is obsolete, superseded, no longer plausible, or unlikely to prevent re-litigation. Repair or delete inbound links. @@ -47,7 +47,7 @@ Keep implemented notes such as: For rejected notes: -- keep folding the compaction package split — 426 words: the package-boundary temptation remains meaningful; +- keep folding the compaction package split — 426 words: the temptation to merge the packages remains meaningful; - delete streaming workflow progress through tool calls — 972 words: its ACP/UI premise is obsolete; - delete dropping ACP terminal metadata — 362 words: the later automation-only ACP decision resolved the question. @@ -65,4 +65,4 @@ After the triplet is sealed, never edit, move, translate, reformat, or delete it Run the archive verifier's focused test, `pnpm run verify-archived-agent-notes`, `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; select any additional evidence through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). -Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the contract intentionally never checks them. +Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index c8a52f5d50..572c36a8a1 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,11 +5,11 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm --silent run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and broken required behavior over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth -- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring contracts. +- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring rules. - [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. @@ -22,22 +22,22 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 1. **New prose receives semantic review.** Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) to critically review every added or changed Markdown passage, JSDoc, comment, prompt, description, diagnostic, and visible string. Verify required coverage, accuracy, placement, and editorial quality against the owning code or behavior; automated checks do not establish those properties. 2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home. 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [subsystems](../../../docs/subsystems/README.md) page and any `type-equiv` entry. Internal types need no catalog entry. -4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md). -5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)). +4. **Registrations clean up.** Verify each new registry contribution passes the disposal tests required by [packages/AGENTS.md](../../../packages/AGENTS.md). +5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at the point where that package can observe it; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package invariant rules](../../../packages/AGENTS.md)). 6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect. ## Manual checks -- **Intent and seam contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. +- **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. -- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an ad-hoc surface widening — require a private capability closure handed to that consumer at construction instead. -- **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR's coherence against [the root contract](../../../AGENTS.md#conventions). +- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an unnecessary API expansion — require a private capability closure handed to that consumer at construction instead. +- **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR against [the root rules](../../../AGENTS.md#conventions). - **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. - **Model perspective:** inspect the exact prompts, tool schemas, results, and diagnostics the model receives across affected modes. Flag concepts outside the model's task, then verify stable text verbatim and dynamic behavior through snapshots or end-to-end coverage. -- **Enforcement boundaries:** follow every denial path to the operation that executes it; exercise direct and alternate callers that can bypass schemas, prompts, facades, wrappers, or listener ordering. -- **Borrowed and derived state:** classify each retained value under the package boundary contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. +- **Enforcement:** follow every denial path to the operation that executes it; exercise direct and alternate callers that can bypass schemas, prompts, facades, wrappers, or listener ordering. +- **Borrowed and derived state:** determine whether each retained value is borrowed or owned under the package contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. - **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. -- **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch Loader export-shape failures; a function plugin must named-export its namespace and have no default export. +- **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch invalid Loader exports; a function plugin must named-export its namespace and have no default export. - **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. - **Invariant lifecycle and negative controls:** verify candidate observations are rejected before publication where possible, session-backed checks reconstruct durable history after late loading or HMR, and a deliberately invalid case fails through the real runner for the intended rule. - **Implemented Agent Notes match shipped reality:** when a PR implements a proposed Agent Note, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 3f93a6560a..5d118f2257 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -38,7 +38,7 @@ Set every `DocsPage` field deliberately: - `order`: stable order within the section. - `sourceAliases`: optional additional repository paths that should resolve to this page when links are projected. It does not create another public route. -Use `mirroredPages()` only for a source that intentionally falls back to the same available language in both route trees. Convert that entry to `pairedPages()` when its counterpart is added. Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. +Use `mirroredPages()` only for a source that intentionally falls back to the same available language in both route trees. Convert that entry to `pairedPages()` when its counterpart is added. Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly expands what the site publishes. ## Preserve link behavior diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index dcbf2f2b21..a42c27cfda 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -5,25 +5,25 @@ description: 'Use when writing, moving, reviewing, or auditing documentation in # Applying the DeepSeek Harness Documentation Standard -The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for required coverage and editorial judgment, and never treat length alone as a defect. +The documentation rules live in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for required coverage and editorial judgment, and never treat length alone as a defect. ## Sources of truth (read, don't re-summarize) - [docs/AGENTS.md](../../../docs/AGENTS.md) — hierarchy, tutorial/reference forms, taxonomy, budgets, and slop checklist. - [.agents/notes/README.md](../../notes/README.md) — when a decision earns an Agent Note, how to file it, and what goes inside one (the header block, per-lifecycle skeleton, and Alternatives-considered mandate, gated by `verify-agent-note-format`); [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem. -- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing contract; editing either side of a pair obligates the counterpart in the same change. +- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing rules; editing either side of a pair obligates the counterpart in the same change. - Root [AGENTS.md](../../../AGENTS.md) — the standing orders whose budget discipline this skill protects. - [Archived Agent Notes](../../notes/archived/AGENTS.md) — frozen historical snapshots excluded from editorial maintenance and evolving documentation gates. ## Review structure before prose -Apply the standard's authoring order to every human-facing document in scope. Do not apply this structural pass to Agent Notes. Classify a postmortem as a reference scoped to one incident; preserve the chronological evidence required by its contract without treating chronology as a teaching sequence. +Apply the standard's authoring order to every human-facing document in scope. Do not apply this structural pass to Agent Notes. Classify a postmortem as a reference scoped to one incident; preserve its required chronological evidence without treating chronology as a teaching sequence. 1. Locate the document in the repository and navigation trees. State its own subject and identify its direct children. -2. Set the detail boundary. Keep full detail about the document's subject, summarize direct children by purpose, responsibility, and high-level behavior, and move deeper explanations to their owning descendants with links. Treat test infrastructure as descendant-owned unless it is the document's subject. +2. Set the permitted level of detail. Keep full detail about the document's subject, summarize direct children by purpose, responsibility, and high-level behavior, and move deeper explanations to their owning descendants with links. Treat test infrastructure as descendant-owned unless it is the document's subject. 3. Classify the document from its intended use, not its path or title. A tutorial must lead through ordered work to an observable outcome; a reference must support lookup within an explicit scope without requiring sequential reading. 4. For a tutorial, privately classify the starting reader and concepts as beginner, intermediate, or advanced. Trace each concept to its prerequisites, reorder premature material, and move optional advanced detail to a later tutorial or reference. -5. Split substantial mixed forms. Keep a small secondary form only behind a clear structural boundary. +5. Split substantial mixed forms. Put a small secondary form in a clearly labeled section. Then check constraints that make placement expensive or wrong: @@ -37,7 +37,7 @@ Then check constraints that make placement expensive or wrong: After the structural pass, hunt the standard's slop checklist with the cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and audit prose introduced by the new base. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. -2. Hunt reasoning-transcript leakage — narrated history, dead design-session citations, review choreography, control-flow narration, test walkthroughs — with [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md), which owns the taxonomy, recall batteries, and the keep/delete boundary. Preserve only a non-obvious contract or durable rationale; the same rationale repeated beside sibling methods keeps one home. +2. Hunt reasoning-transcript leakage — narrated history, dead design-session citations, review choreography, control-flow narration, test walkthroughs — with [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md), which defines the taxonomy, recall batteries, and rules for what to keep or delete. Preserve only a non-obvious contract or durable rationale; the same rationale repeated beside sibling methods keeps one home. 3. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links. 4. Replace hand-written catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference. 5. In `implemented/` Agent Notes, remove migration plans, acceptance-task checklists, and future-tense spec language. Keep concise verification contracts that identify the behaviors and tiers pinning the shipped decision, plus named coverage gaps. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 33e5b4fd88..88b025e298 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -11,19 +11,19 @@ This skill helps turn a broad "find things to simplify" request into evidence-ba - Read `AGENTS.md`, especially the pre-release stance and the conventions (including the tests-are-not-golden-truth and Agent Notes-are-not-golden-truth doctrines), plus [docs/defensive-patterns.md](../../../docs/defensive-patterns.md) and [docs/testing.md](../../../docs/testing.md). - Skim [docs/architecture.md](../../../docs/architecture.md) before judging anything under `packages/`; simplifications that fight the service map or event taxonomy need extra evidence. -- Use the Agent Note tree and its [contract](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend Agent Notes. +- Use the Agent Note tree and its [rules](../../notes/README.md) to understand intentional architecture. The most relevant implemented examples are [drop mutable session summary](../../notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.md), [shared persistence write coordinator](../../notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md), [capability seams](../../notes/implemented/architecture/2026-06-13-capability-seams.md), and the twin adapter / dual persistence backend Agent Notes. - Treat dual LLM adapters and dual persistence backends as intentional by default. Do not propose deleting either twin/backend as "low effort" unless the user explicitly overrides that constraint. Removing an unused method or hook inside a protected seam can still be valid if it does not collapse the protected design. ## What Counts As A Strong Candidate -A strong simplification removes, folds, or demotes something real and has clear evidence that the current shape costs more than it buys: +A strong simplification removes, folds, or demotes something real and has clear evidence that the current design costs more than it buys: - A public method, event, config knob, registry notification, helper, package, durable event, or test artifact has no production consumer. - Tests or docs are the only consumers, and the behavior they pin is not load-bearing. - Two representations mirror the same fact, especially across durable session events and transient `agent/*` events. - A seam has methods every implementation must support but no consumer uses. -- A package boundary exists only for test/demo/support code and adds publish or dependency overhead. -- A feature implements speculative product generality: multi-session/session-load, background task rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar shapes with no product owner. +- A separate package exists only for test/demo/support code and adds publish or dependency overhead. +- A feature implements speculative product generality: multi-session/session-load, background task rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar designs with no product owner. - An invariant, rollback path, set of expected outputs, or special-case test exists only to protect an unused surface. - Hand-rolled code reimplements what a well-maintained external package or a Node builtin at the engine floor already provides, and the swap would delete the implementation plus its dedicated tests ([dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)). - The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain. @@ -38,7 +38,7 @@ Use parallel subagents when the user asks for breadth or many candidates. Give e - ACP automation and human UI surfaces: prompt settlement and teardown on the protocol side; transcript rendering and interaction state on the UI side. - LLM/tools/system prompt: stream/generate surfaces, assemblers, registries, tool schema defaults, presentation hooks. - Bash and tool execution: foreground/background split, task ownership, output spill files, executor methods. -- Packages/examples/scripts/tests: package boundaries, static inventories, redundant snapshot expected outputs, support packages. +- Packages/examples/scripts/tests: package splits, static inventories, redundant snapshot expected outputs, support packages. If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey. @@ -46,9 +46,9 @@ Start with the largest production-code deltas. A broad simplification audit that ## Audit Trust And Lifecycle Boundaries -Classify every defensive copy, freeze, validator, and callback capture by the boundary it crosses. Same-process typed service/plugin calls ordinarily borrow readonly values; parser/config, queue, model/tool JSON, durable/file, worker, process, and wire boundaries own or validate data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. +For every defensive copy, freeze, validator, and callback capture, name where the value came from and who owns it next. Same-process typed service/plugin calls ordinarily borrow readonly values; parsers, config loaders, queues, model/tool JSON, durable files, workers, processes, and wire decoders own or validate their data. Tests built around hostile getters, fake typed objects, callback replacement, or mutation after a same-process handoff are evidence of a potentially speculative contract, not automatic justification for keeping it. -For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects a real boundary: synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence. +For complex asynchronous code, draw the ownership graph and map each sentinel, readiness promise, cancellation path, disposer, and state flag to a distinct owner or transition. When several mechanisms mirror the same liveness or settlement fact, propose one transaction or lifecycle controller instead. Preserve separate machinery where it protects synchronous publication and rollback, callback containment, first-terminal-outcome arbitration, worker/process ownership, or dispose-to-quiescence. ## Hand-Rolled Code Versus A Dependency @@ -75,7 +75,7 @@ Reject or downgrade a candidate when: - A production caller exists and the simplification would be a feature decision rather than a cleanup. - The surface is explicitly justified by an implemented Agent Note or a hard-won defensive pattern, and the new evidence does not beat that reason. -- The removal would force unrelated churn without actually making the contract smaller. +- The removal would force unrelated churn without actually reducing the public API or required behavior. - The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md). ## Coalesce Superseded Agent Notes @@ -84,23 +84,23 @@ Audit the Agent Note tree when the user asks to reduce or coalesce it, or when t Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Low-future-value implemented notes move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. -Follow the deletion rule in the [Agent Note contract](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: +Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: 1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof. 2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial. -3. For full supersession, move every unique rationale, alternative, consequence, shipped verification contract, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. +3. For full supersession, move every unique rationale, alternative, consequence, shipped verification evidence, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. 4. Repair every inbound link, then delete the English note, Chinese counterpart, and consistency record together. 5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current. -An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification contracts. +An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification evidence. Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone. ## Write The Agent Note -Create one file per durable proposal under `.agents/notes/<lifecycle>/<class>/yyyy-mm-dd-topic.md`, following the lifecycle/classification contract in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. +Create one file per durable proposal under `.agents/notes/<lifecycle>/<class>/yyyy-mm-dd-topic.md`, following the lifecycle and classification rules in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. -Prefer this shape, adjusting when the idea needs it: +Prefer this structure, adjusting when the idea needs it: - `# Agent Note: <action-oriented title>` - `Status: proposed` diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index 2381d459c2..6ad226baf4 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -5,7 +5,9 @@ description: Use when writing, reviewing, restoring, trimming, or auditing prose # DeepSeek Harness Prose Standard -Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates, and [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md) for hunting and fixing reasoning-transcript leakage. It is guidance, not a script. +Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. A contract is an obligation, invariant, precondition, postcondition, or compatibility promise that a caller, callee, implementer, producer, or consumer relies on. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates, and [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md) for hunting and fixing reasoning-transcript leakage. It is guidance, not a script. + +Treat `contract`, `boundary`, `shape`, `surface`, `seam`, `gate`, and `vocabulary` as terms to check before use, not banned words. First ask whether the exact rule, API, field set, type, validation, timing point, component split, or failure states the fact better. Keep a term when it names the exact technical subject, including caller/callee contracts and security/process boundaries. Comments describe non-obvious contracts or rationale that code cannot express; they do not restate what code already implies. @@ -45,14 +47,14 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Public JSDoc:** document caller-visible return distinctions, throws or rejections, side effects, ownership, timing, cancellation, and durability. - **Internal comments:** orient non-local structure and obviously complicated local structure, including invariants, race ordering, ownership, security boundaries, and surprising failure behavior. Delete control-flow narration and code restatement. -- **Module comments:** state the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation. +- **Module comments:** state the module's role, dependencies, responsibilities, and non-obvious architecture choices; link architecture choices to their owning explanation. - **Tests:** explain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories. - **Cookbooks:** include prerequisites, required actions, the real entry path, observable verification, and concise warnings. -- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Quote stable model-visible text owned by the package; link generated catalogs and cross-package owners. Keep durable gaps and maintainer traps, not ordinary cleanup inventories. Follow the [package README contract](../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). -- **Agent Notes:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps. Implemented Agent Notes state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. +- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Quote stable model-visible text owned by the package; link generated catalogs and cross-package owners. Keep durable gaps and maintainer traps, not ordinary cleanup inventories. Follow the [package README requirements](../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). +- **Agent Notes:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification evidence, and named coverage gaps. Implemented Agent Notes state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. -- **Examples and configuration comments:** explain boundaries, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. +- **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. - **Prompts and visible strings:** treat wording as behavior. Inspect generated output and run behavior validation or state why no snapshot applies. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. @@ -70,7 +72,7 @@ Preserve searchable mechanism names and meaningful modal, temporal, or negative ## Borderline decisions -A case is borderline only when at least two versions satisfy the complete-proposition rule but trade accepted principles, and this skill does not already resolve the tradeoff. A new prose shape with one contract-preserving answer is not borderline. +A case is borderline only when at least two versions satisfy the complete-proposition rule but trade accepted principles, and this skill does not already resolve the tradeoff. A rewrite with one proposition-preserving answer is not borderline. In automatic mode, apply clear edits when authorized and report genuine borderline cases without asking questions. Do not weaken a proposition to make progress. diff --git a/.agents/skills/dsh-prose-standard/references/examples.md b/.agents/skills/dsh-prose-standard/references/examples.md index c4270ecc98..cb3f616b71 100644 --- a/.agents/skills/dsh-prose-standard/references/examples.md +++ b/.agents/skills/dsh-prose-standard/references/examples.md @@ -40,13 +40,13 @@ Keep the test tiers, required action, real entry path, and observable verificati **Over-detailed:** A chronological account of every promise and callback used to implement teardown. -The actor, ordering, ownership boundary, and completion guarantee are separate factual clauses. +The actor, ordering, point where ownership changes, and completion guarantee are separate factual clauses. ## Event JSDoc preserves boundary timing **Over-trimmed:** “Composes and caches the session prefix.” -**Balanced:** “Composes the session prefix once before the first pre-step and request boundary. Listener appends join the current request, and pre-step pressure accounting receives the composed prefix.” +**Balanced:** “Composes the session prefix once before the first pre-step and model request. Listener appends join the current request, and pre-step pressure accounting receives the composed prefix.” **Over-detailed:** A walkthrough of the loop helpers, cache fields, and promise callbacks that implement the ordering. @@ -60,7 +60,7 @@ Event order and its current-request consequence are caller-visible behavior, not **Over-detailed:** A paragraph-by-paragraph preview of the classes and helper functions below. -Keep role, boundaries, and non-obvious lifecycle behavior. Link architecture rationale and let the code show local control flow. +Keep the module's role, dependencies, responsibilities, and non-obvious lifecycle behavior. Link architecture rationale and let the code show local control flow. ## Public JSDoc includes failures @@ -76,11 +76,11 @@ Throws and state preconditions are caller-visible contract facts. **Over-trimmed:** “Search provider backed by an external API.” -**Balanced:** “Maps each provider result to the shared search-result shape, preserving the title, URL, and text while omitting provider-only ranking metadata.” +**Balanced:** “Maps each provider result to the shared search-result fields, preserving the title, URL, and text while omitting provider-only ranking metadata.” **Over-detailed:** A field-by-field restatement of the mapping code, including fields with identical names and obvious assignments. -Keep mapping details that explain an abstraction boundary or intentional information loss. +Keep mapping details that explain where an adapter drops or changes information. ## Link rationale while keeping the local contract @@ -110,7 +110,7 @@ Remove migration tasks and test narration. Keep the tiers, behaviors they pin, r **Over-detailed:** A list of every service a plugin could misuse and every hypothetical exploit. -Keep one example when it makes an otherwise abstract boundary operationally clear. +Keep one example when it makes an otherwise abstract security limit operationally clear. ## Delete reasoning transcripts entirely @@ -134,7 +134,7 @@ Keep the consequence of order, a surprising scope rule, or a security boundary. **Shorter but worse:** “The adapter normalizes provider errors.” -**Balanced decision:** Keep the current sentence unless a link or surrounding contract already carries the failure categories. The shorter version loses the consequence and distinctions without improving structure. +**Balanced decision:** Keep the current sentence unless a link or surrounding contract already lists the failure categories. The shorter version loses the consequence and distinctions without improving structure. ## Model-visible text follows ownership @@ -154,7 +154,7 @@ Wording that reaches a model is behavior, but duplication still drifts. Exactnes **Balanced:** “Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.” Keep non-catalog detail in later sentences. -Know what the generator extracts. That fragment must preserve the contract needed on its generated surface. +Know what the generator extracts. That fragment must preserve the contract needed on its generated output. ## Limitations are contracts, not debt inventories diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index e35096a5a2..586eb554c8 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -64,4 +64,4 @@ When translations need to be written from scratch, the orchestrating agent does ## How to respond to translation review -Follow the [code-review reporting guidance](../dsh-code-review/SKILL.md#reporting-findings): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. +Follow the [code-review reporting guidance](../dsh-code-review/SKILL.md#reporting-findings): evaluate each comment on its merits, and for terminology comments, remember the terminology table is the contract — apply a reviewer's rendering decision to [terminology.md](../../../docs/i18n/terminology.md), not only to one file. diff --git a/.agents/skills/dsh-trim-cot-leakage/SKILL.md b/.agents/skills/dsh-trim-cot-leakage/SKILL.md index c80dfbd465..7fad4d50c2 100644 --- a/.agents/skills/dsh-trim-cot-leakage/SKILL.md +++ b/.agents/skills/dsh-trim-cot-leakage/SKILL.md @@ -24,7 +24,7 @@ For every suspect passage ask: **could a reader at HEAD, with no access to any s ## What is not leakage -The citation boundary is where unaided passes fail in both directions — deleting durable references and keeping dead ones. Apply these keeps as written; [examples](references/examples.md) calibrates each: +Unaided citation passes fail in both directions by deleting durable references and keeping dead ones. Apply these keep rules as written; [examples](references/examples.md) calibrates each: - **Issue references** — `#1470`, `TODO(name):`, "issue #N owns the follow-up" resolve at HEAD; keep them on any surface, including READMEs. Do not relocate them to Agent Notes. - **Merged-PR and issue citations inside Agent Notes and postmortems** — sanctioned evidence per the [documentation standard](../../../docs/AGENTS.md)'s change-story routing. @@ -39,7 +39,7 @@ The citation boundary is where unaided passes fail in both directions — deleti ## Workflow 1. Scope and exclusions per [dsh-prose-standard](../dsh-prose-standard/SKILL.md): require an explicit scope; never touch `vendor/`, `.agents/notes/archived/`, or recorded fixtures and snapshots — recorded model output and sealed history keep their original voice. -2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge surfaced shapes the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. +2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge found cases the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand. 3. Fix owner-first per surface: generated catalogs → fix the source JSDoc or generator template, then regenerate; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual pairs → update the counterpart and re-record per [dsh-translate-docs](../dsh-translate-docs/SKILL.md); model-visible strings → wording is behavior, so flag for a snapshot-backed change instead of silently rewording. 4. Before deleting anything, enumerate the passage's propositions (prose-standard) and check the [overcorrection traps](references/examples.md#overcorrection-traps): trims that flip an obligation into an endorsement, promote a hypothetical to a shipped feature, delete a true fact, or drop provenance. 5. Verify: re-run the batteries expecting only sanctioned keeps, this skill's own directory, and the owning note's quoted evidence; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`). diff --git a/.agents/skills/dsh-trim-cot-leakage/references/examples.md b/.agents/skills/dsh-trim-cot-leakage/references/examples.md index 22e73e10f4..9afc22c209 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/examples.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/examples.md @@ -1,6 +1,6 @@ # Few-shot leakage examples -Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere. +Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked wording as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere. ## Dead citations diff --git a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md index 8f9cce4aad..9b1c79054c 100644 --- a/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md +++ b/.agents/skills/dsh-trim-cot-leakage/references/recall-batteries.md @@ -1,12 +1,12 @@ # Recall batteries -Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. Every hit needs semantic judgment — the batteries over-match by design, and they under-match by nature: each review round of the purge found shapes no battery caught, so pair them with an unpatterned read of the densest prose in scope. +Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. Every hit needs semantic judgment — the batteries over-match by design, and they under-match by nature: each review round of the purge found cases no battery caught, so pair them with an unpatterned read of the densest prose in scope. ## Invocation rules - Add `--hidden --glob '!.git/**'` so `.agents/` is searched; ripgrep skips dot-directories by default and the purge's biggest miss risk was Agent Notes. -- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. -- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the code-shaped first line stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. +- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked wording as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage. +- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the first line, which matches code patterns, stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise. - A zero-hit pattern proves nothing until you have seen it match: test it against a known-positive string before trusting the negative. ## English battery diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index d7df478074..3566dfb763 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -15,7 +15,7 @@ A pull request that changes product-user-visible GUI behavior MUST include a dem The recording itself is part of the evidence: use a real server booted from that pull request's branch tree, a real API key, and real model rounds. Never substitute fixture queries, mock transports, synthetic event injection, or test-only hooks unless the user explicitly asked for a fixture recording. Next to the embed, state the exact demonstrated commit SHA, the tree and origin that served it, any mode flags or browser-state exceptions, and whether a real model round ran, so reviewers know exactly what the recording proves. -## Keep the boundary explicit +## Keep recording separate from publication - Recording produces frame images and one local `.gif` artifact only; it never mutates remote state. - Publication — pushing the GIF to an assets branch and embedding it in a pull request body — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. @@ -33,9 +33,9 @@ A GIF for a specific pull request demonstrates that pull request's tree, so stag ## Record the flow -1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required; state that exception next to the GIF and do not claim fresh client state. If browser control is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback next to the GIF. -2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. -3. When a production default opens a native operating-system surface that headless automation cannot drive, select an official browser-operable production backend through the application's normal configuration. State that override next to the GIF; a fixture, mock transport, or test-only hook is not an acceptable substitute. +1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required; state that exception in the provenance and do not claim fresh client state. If browser control is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback in the provenance. +2. Before recording, identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. +3. When a production default opens a native operating-system surface that headless automation cannot drive, select an official browser-operable production backend through the application's normal configuration. State the override in the provenance; a fixture, mock transport, or test-only hook is not an acceptable substitute. 4. Choose three to six states that tell one story, such as typed, running, settled, and detail. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. 5. Keep one viewport and crop for every frame, and name frames lexically: `00-initial.png`, `01-typed.png`, and so on. 6. Store frames under the repository's gitignored `.playwright-mcp/` directory — browser-tool screenshots can only be written under the tool's allowed roots, and relative filenames resolve against the repository root. Create the frame subdirectory first (`mkdir -p .playwright-mcp/gif-frames-<label>`); writing into a missing directory fails with ENOENT at capture time. diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index c1a70c77da..2125ba2f36 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -109,7 +109,7 @@ function firstNonblankLine(body) { } /** - * Validate body shape and Owner against assignees. + * Validate required body sections and check Owner against assignees. * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. * @returns {string[]} Validation errors. */ @@ -144,7 +144,7 @@ export function validateBody({ } /** - * Decide whether a PR has entered the human-review enforcement boundary. + * Decide whether the human-review policy applies to a PR. * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state. * @returns {boolean} Whether the PR policy is mandatory. */ diff --git a/AGENTS.md b/AGENTS.md index 7e481c62b7..a8a202147c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,9 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. +- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). +- **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). @@ -120,7 +120,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). -- **Plan unit, e2e, and snapshot coverage** for new capability seams, lifecycle shapes, and transcript surfaces; add missing snapshot-harness support in the same change. +- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). @@ -134,13 +134,13 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why narrowing is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring Service Definition, protocol, or class. -Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each new or changed acceptance path rejects an invalid case. Use narrow justified exceptions instead of disabling a rule globally. +Comments and docs state complete contracts and context, not reasoning transcripts. Use direct, concrete terms. Do not use metaphors. Before writing `contract`, `boundary`, or `shape`, ask whether a more exact term names the subject: write `response fields`, `JSON validation`, or `ESM exports` instead of `response shape`, `validation boundary`, or `module shape`. Keep `contract` for preconditions, postconditions, invariants, compatibility promises, and other obligations that callers, callees, implementers, providers, producers, or consumers rely on. Keep a literal process, wire, security, transaction, or lifecycle boundary. Do not narrate control flow or tests, preserve review history, or restate code. Keep behavior, failure, timing, ownership, and safe-use facts; link the rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each changed acceptance path rejects an invalid case. Use narrow, justified exceptions instead of disabling a rule globally. -Docs accompany every code change: update affected README/JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). +Docs accompany every code change: update affected README and JSDoc contracts together; update both sides of a bilingual pair and re-record it ([i18n contract](docs/i18n/README.md)). Current-state prose, one physical line per paragraph, one home per fact, and word budgets live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions -`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space. +`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the required content genuinely needs more space. ## Vendoring policy diff --git a/README.i18n.yaml b/README.i18n.yaml index 695f81e459..35cb82e776 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: ff4cf661a060772973629844d43758d26ac1be02 -README.zh.md: a11834c590a3930e881447d79bbfeb7a10ee3a92 +README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb +README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 diff --git a/README.md b/README.md index ff4cf661a0..3174630d02 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ dsh plugin --profile tui add <package> # install a plugin into a custom profile dsh --profile tui # boot it ``` -The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. +The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. ### Headless diff --git a/README.zh.md b/README.zh.md index a11834c590..377ea6372a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -49,7 +49,7 @@ dsh plugin --profile tui add <package> # install a plugin into a custom profile dsh --profile tui # boot it ``` -profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)约定](apps/cli/README.md#profiles)。 +profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。 ### Headless diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 65d2716458..d61c4c545f 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -124,7 +124,7 @@ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index f2cdeea159..a6212af84e 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -105,7 +105,7 @@ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 3810ec334b..f6d9a6126c 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -53,13 +53,13 @@ When a preset genuinely owns a service, wrap the provider **and every consumer t A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. -Registry-shaped host capabilities need no realm at all: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. +Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. ## Verifying a change Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it. -To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. +To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 66407faf1d..f2f0122948 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -117,7 +117,7 @@ Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index cb7b0a1ebf..4b5aed6cd2 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 40807da0b1a88708e2e5e9efa3569bd0d6f6a020 +README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91 README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 40807da0b1..0b5faf8993 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -61,7 +61,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy `DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. -`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. +`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. ## Shared deployment behavior diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index e2e8a89980..97f5222398 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -138,7 +138,7 @@ Examples: resolved = { mode: 'profile', profile, patches } }) - /** Reject parent options that crossed a subcommand boundary. */ + /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { const parent = program.opts<{ profile?: string diff --git a/apps/cli/src/process-shutdown.ts b/apps/cli/src/process-shutdown.ts index 7ef9d32632..e34f85f7f3 100644 --- a/apps/cli/src/process-shutdown.ts +++ b/apps/cli/src/process-shutdown.ts @@ -14,8 +14,8 @@ export interface ProcessShutdown { /** * Create one process-exit controller around an application disposer. * @param dispose - Whole-application teardown that resolves at quiescence. - * @param forceExit - Forced process exit boundary, replaceable by tests. - * @param complete - Natural process completion boundary, replaceable by tests. + * @param forceExit - Function that exits the process immediately, replaceable by tests. + * @param complete - Function that records the natural completion code, replaceable by tests. * @param timeoutMs - Grace before forced exit, replaceable by tests. * @returns A controller whose normal calls coalesce and whose repeated signal call escalates. */ diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts index 7069b22738..818b8f4561 100644 --- a/apps/cli/tests/memory-mcp-configs.spec.ts +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -1,6 +1,6 @@ /** * The third-party memory examples stay config-only. This suite parses every - * checked-in overlay, verifies its pin/transport/secret boundary, then replaces + * checked-in overlay, verifies its package pin, transport, and secret handling, then replaces * only the upstream endpoint with the package-owned keyless MCP fixture and * proves the real Cordis Loader discovers a tool through the generic bridge. */ @@ -81,7 +81,7 @@ async function waitForTool(ctx: Context, name: string): Promise<void> { } describe('third-party memory MCP example overlays', () => { - it.each(examples)('parses $file with the documented generic boundary', (contract) => { + it.each(examples)('parses $file with the documented generic plugin fields', (contract) => { const file = resolve(exampleDir, contract.file) const source = readFileSync(file, 'utf8') const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file)) diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 6ce11dc7f0..bd2268231b 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' /** * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts` * with the exact production launch vector (`node --import tsx/esm`, the same - * shape as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the + * executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index 9911742633..e57367ee46 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -35,9 +35,9 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() // Irreducible payload: the command has to be long enough to pass the card's -// height cap, which is the only shape that reproduces an action row pushed off -// screen. Unrelated tokens, not a repeated word — the model compresses a -// repeated word into `printf 'alpha %.0s' {1..400}` when recording, and a +// height cap, which is the only command length that reproduces an action row pushed off +// screen. Unrelated tokens, not a repeated word — a repeated word is what the +// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a // short command proves nothing here. The formula keeps the source small; the // model receives the expanded literal it has to put in the command. const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ') diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index ad9d6a6874..aca85146b5 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -186,7 +186,7 @@ describe('web e2e: long Chat interaction contract', () => { const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) - if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`) + if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`) const expectedUserText = textContent(branchUserEvent.data.content) await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index 2daed2c0f6..d3c27f0ce0 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -1,7 +1,7 @@ // Opt-in browser benchmark for high-cardinality workspace and history // rendering. It reports measurements without timing assertions because host -// speed is not a correctness contract; structural assertions keep the load -// shape from silently shrinking. +// speed is not a correctness contract; structural assertions keep the number +// of workspaces and history entries from silently shrinking. import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 687419dc53..0ec3ef63e1 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -64,12 +64,12 @@ const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => { }).join('\n') /** - * A draft ending in a newline: the shape where the two layers reserve their + * A draft ending in a newline, where the two layers reserve their * final line box on different terms. A textarea keeps one for the caret after a * final newline; `white-space: pre-wrap` collapses a text node's trailing * newline and generates none. The hidden auto-grow mirror carries the newline * and so decides the height for both, which is why the backdrop needs no - * padding of its own — but only a draft of this shape can show it. + * padding of its own — but only a draft with a trailing newline can show it. */ const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n` @@ -381,7 +381,7 @@ describe('web e2e: composer draft scrolling', () => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - // Ending in a newline is the shape the engines disagree on: the caret + // The engines disagree when the draft ends in a newline: the caret // lands on a line with nothing on it, where chromium reports no client // rects at all for the collapsed position. }, `\n${DRAFT}\n`) @@ -401,7 +401,7 @@ describe('web e2e: composer draft scrolling', () => { it('a draft ending in a newline scrolls to its true end, not a line above it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline')) - // The layers reserve a final line box on different terms, so this shape is + // The layers reserve a final line box on different terms, so the trailing-newline case is // the one that separates a height every layer agrees on from a box measured // one line short of the caret's own last position. const input = page.locator('textarea:enabled').first() @@ -453,7 +453,7 @@ describe('web e2e: composer draft scrolling', () => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - // The ordinary shape — not ending in a newline — so the collapsed branch + // The ordinary case, without a trailing newline, so the collapsed branch // of the reveal keeps a real engine under it; the case above owns the // after-newline branch. }, `\n${DRAFT}`) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index c88a823fb7..7f507c59d2 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -42,7 +42,7 @@ function appFrame(page: Page) { return page.locator('[style*="grid-template-columns"]').first() } -/** Render the two boundary affordances without platform-dependent coordinates. */ +/** Render the two column-resize handles without platform-dependent coordinates. */ async function handleSnapshot(page: Page): Promise<string> { const handles = await page.locator('[class*="handle"]').evaluateAll(elements => elements.map(element => ({ diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 9abc8b4b6d..b96a1fa393 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -164,8 +164,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { sessionId = await settled } await recordFixture(scaffold, sessionId!, SEED) - // Fixture honesty: the recording must carry the shape the replay - // scenarios assert on — three calls in turn 1 and two closed turns. + // Fixture honesty: the recording must contain the events the replay + // scenarios assert on: three calls in turn 1 and two closed turns. const recorded = parseSessionLog(await readFile(SEED, 'utf8')) expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2) const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call') diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts index e1b92ed2e3..85b52a734f 100644 --- a/apps/web/tests/pwsh-terminal.e2e.ts +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -1,7 +1,7 @@ // Keyless browser regression for pwsh UI parity with bash: a seeded session // whose pwsh call/result is presented by the REAL tool-pwsh on replay (the // api-proxy recomputes presentation views from logged args/result content) -// must render as a bash-shaped terminal card with the parsed exit-status +// must render with the same terminal card layout as bash and show the parsed exit-status // pill — not a generic console-fenced card. The seed is authored, not // recorded: its header line carries no `cwd` // field (seedSession writes the session cwd itself, and a Windows temp path @@ -43,7 +43,7 @@ const HAS_PWSH = MODE === 'record' ? false : spawnSync( { encoding: 'utf8' }, ).status === 0 -describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => { +describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls use the bash terminal-card layout', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -75,7 +75,7 @@ describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as b await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) await result.click() await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 }) - // The tool row is expand-gated: the settled bash-shaped row carries the + // The tool row is expand-gated: the settled row uses the bash layout and carries the // shell-family variant, and the terminal card lives in the expanded body. const row = page.locator('[data-tool="pwsh"]').first() await row.waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 6f865567bb..5e16453a53 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -31,7 +31,7 @@ const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below -// needs option copy that WRAPS, which is the only shape that reproduces a +// needs option copy that WRAPS, which is the only text layout that reproduces a // collapsed row painting its copy outside its own box. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' @@ -116,7 +116,7 @@ describe('web e2e: resident question composer round trip', () => { return { rows: rows.length, spill: Math.max(...spill), - // Wrapped copy is the shape that overflows a collapsed row, and a + // Wrapped option text is what overflows a collapsed row, and a // scrolling list proves the seat is genuinely capped. Without both, // the spill assertion would hold vacuously. wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0c5524fc42..eebdd3a8a8 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -443,7 +443,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We let replayHandle: ReplayHandle | undefined try { process.chdir(workspaceCwd) - // The production resolution shape: an empty profile root inside the temp + // The production module-resolution setup: an empty profile root inside the temp // harness home, with bare plugin names resolving through the flat module // fallback the launcher heals under <home>/profiles. healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome) @@ -621,7 +621,7 @@ export function fixtureUserPrompts(fixtureText: string): string[] { * through the REAL backend API (throwaway Context + SessionStore + JSONL * plugin — the semantic-checkpoint precedent), never raw file writes: no * knowledge of bucket hashing, filename encoding, or compression, and - * malformed shapes fail loud at seed time. The fixture's tokenized identity + * malformed session events fail loud at seed time. The fixture's tokenized identity * ({{sessionId}}/{{cwd}}) is realized for this world before parsing. * @param scaffold - the target scaffold. * @param fixtureText - raw recorded session.jsonl contents. @@ -725,7 +725,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { // between local worktrees and CI scratch directories. .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2') // Message IconActions clocks widen by calendar day/year; collapse every - // shape so goldens stay stable across midnight and year boundaries. + // format so goldens stay stable across midnight and year changes. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}') diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 8ac775be1d..3e94faa16d 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -4,7 +4,7 @@ // FixtureApiClient transport (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but -// carries no behavior assertions by contract; this is the assembled-output check +// intentionally carries no behavior assertions; this is the assembled-output check // that a broken SearchRow registration or a dropped card would fail — the // per-package suites bench over src and cannot see the bundled wiring. // @@ -13,7 +13,7 @@ // fixture, not harvested from a live model. The recovery-footer arm is a pure // derivation over the result view, pinned at every render site by the // ui-conversation suite; here the fixture turn exercises the assembled card -// shape and its cap. +// fields and its cap. import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -24,7 +24,7 @@ const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep- installAssembledBootEnv() -/** Normalize a rendered search card to a stable text shape: the kind, the banner +/** Normalize a rendered search card to stable text fields: the kind, the banner * summary, each file header (path + count), each visible match line, the expand * control label, and the recovery footer. */ function cardShape(root: Element): string { @@ -63,7 +63,7 @@ describe('assembled search card', () => { }, { timeout: 10_000 }) // `data-tool` sits on the ToolRow root; the collapsed row is the expand - // toggle. Click it so the card and its recovery footer mount, then shape the + // toggle. Click it so the card and its recovery footer mount, then serialize the // whole row (the card lives inside ToolRow's body wrapper). const grepRow = document.querySelector('[data-tool="grep"]')! act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 257da0031a..a112933f9c 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -102,7 +102,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string { }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator - // prices message CONTENT only, so a minimal wrapper per storage shape is + // prices message CONTENT only, so a minimal wrapper for each stored event format is // exact — pre-identity rows carry bare `content` (the persistence read path // upgrades them), a current row carries the full `message` envelope. const priceRow = (row: (typeof events)[number]): number => { @@ -169,7 +169,7 @@ function withCompaction(raw: string, meter: TokenMeterService): string { }, }) // The persistence seed helper requires a terminal turn/end. Keep the manual - // command standalone, then add a closed zero-step fixture boundary after it. + // command standalone, then add a closed zero-step turn after it. const closureTurn = lastTurn + 1 at({ type: 'turn/start', data: { turn: closureTurn } }) at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 5d929044b3..25d1b2c0dc 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -90,7 +90,7 @@ it('assembles the shipped Web catalog with the confined access default', async ( // `workspace-write` is not "the workspace and nothing else": the shared roots // helper always admits the temp directories too. Pinning it against an // explicit mode keeps the claim independent of this surface's default, and - // keeps a future boundary test from being run inside /tmp — where an + // keeps a future sandbox-confinement test from being run inside /tmp — where an // "escape" write succeeds by design and reads as a sandbox failure. expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual( expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]), diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index bf5f920458..bbb064c119 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -17,11 +17,10 @@ // replacing those nodes. // // The round-trip against a loopback host is far too fast to observe, so this -// scenario HOLDS the `session.history` response open at the browser's network -// boundary and asserts the visible frame while it is in flight. That gate is -// what makes the assertions non-vacuous: without the phase exemption, the -// held window is exactly when `settling` would be painted and the composer -// hidden. +// scenario HOLDS the `session.history` response open in the browser's network +// handler and asserts the visible frame while it is in flight. That wait is +// what makes the assertions non-vacuous: without the phase exemption, the held +// window is exactly when `settling` would be painted and the composer hidden. // // Zero model calls: registering a workspace and opening its blank session are // host RPCs with no model involvement. A stray stream would fail loud with diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index ff43cee39b..8186905f62 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -20,7 +20,7 @@ const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/todo-row/parallel installAssembledBootEnv() -/** Normalize the todo row and the plan strip to a stable text shape: the row's +/** Normalize the todo row and the plan strip to stable text fields: the row's * title, its truncatable summary, its non-shrinking suffix, then the panel's * per-status header and every list item with its status. */ function todoShape(row: Element, panel: Element): string { diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index d68aef4700..4822a254cd 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: assistant IconActions belong to the settled answer, so // they arrive with `turn/end` and not before. The recorded turn narrates in -// plain text before its tool call, which is the shape that would hand the -// footer to mid-turn narration for the seconds a tool runs and then move it +// plain text before its tool call, which is the event order that would show the +// footer beside mid-turn narration for the seconds a tool runs and then move it // down. A `hang` sidecar on the SECOND model call parks the turn after the // narration and the tool result are durable, so the running state is stable by // construction rather than by timing; stopping from that park writes the diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index a8e238422b..c18a522d30 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -31,8 +31,8 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' -// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled -// to that contract if the shared grace tuning changes. +// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them above +// that value if the shared setting changes. const POINTER_TRANSIT_MS = 300 const POINTER_HOLD_MS = 600 diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ec34ec435a..a8323cdd78 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -31,7 +31,7 @@ function rejectStandaloneServe(): Plugin { * editing shell code re-hashes only index and returning clients keep the * cached vendor chunk. * - * Boundary invariant: every member must be react-free. A package that + * Every member must be React-free. A package that * imports react/jsx-runtime must never be listed — rollup folds a module * shared between the entry and a manual chunk into the manual chunk, so one * react-importing member would drag the single shared react copy into @@ -77,8 +77,8 @@ const BOOT_GRAMMAR_FILES: readonly string[] = [ const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf'] /** - * npm package name of a resolved module id (the segment after the LAST - * `node_modules/` — pnpm nests the real package under an inner node_modules). + * npm package name of a resolved module id: the segment after the last + * `node_modules/`. pnpm nests the real package under an inner node_modules. */ function npmPackageOf(id: string): string | undefined { const parts = id.split('/node_modules/') diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 04cd0f22f0..64ea632cb6 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -6,7 +6,7 @@ This file defines document structure, Markdown tiers, writing rules, and `verify These rules apply to human-facing documentation; [Agent Notes](../.agents/notes/README.md) remain outside their scope. A [postmortem](postmortem/README.md) is an incident-scoped reference; chronology records evidence, not a teaching sequence. A document's subject and tree position fix its scope: describe its own subject at appropriate detail and direct children only by purpose, responsibility, and high-level behavior; link to the owning descendant for lower-level detail. Document type does not widen that scope. A reference may be exhaustive only about its own subject. Testing mechanisms, fixtures, and harnesses belong at the lowest owning level; higher documents link there. -Classify every in-scope document as a tutorial or reference. A tutorial follows an ordered path to an outcome and introduces only what each step needs. A reference defines a lookup scope and describes current behavior without depending on a teaching sequence. Separate substantial tutorial and reference content; use a clear structural boundary when either part is small. +Classify every in-scope document as a tutorial or reference. Tutorials follow an ordered path to an outcome and introduce only what each step needs. References define a lookup scope and current behavior without a teaching sequence. Separate substantial tutorial and reference content; label a section when either part is small. Before writing a tutorial, privately classify the reader's starting knowledge and each concept as beginner, intermediate, or advanced. Establish prerequisites before dependent concepts, increase difficulty gradually, and move unnecessary advanced material to a later tutorial or reference. @@ -20,18 +20,18 @@ Each fact has one home: the tier whose job it is; elsewhere, link there. |---|---|---| | Root `AGENTS.md` | Standing orders: rules an agent needs in context in every session, one to three lines each, linking its home | Stories, worked examples, situational procedures, anything restated from a linked home | | Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries | -| [architecture.md](architecture.md) | System map: services, loop, capability seams, extension points — read before changing `packages/` | Type shapes (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | -| [subsystems/](subsystems/README.md) | One reference page per subsystem: type shapes, semantics, and the generated Cordis surface | Behavior narration (→ architecture.md) | -| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and verification contract; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | +| [architecture.md](architecture.md) | System map: services, loop, capability seams, extension points — read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations | +| [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) | +| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) | | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | -| [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), gate-by-gate enumerations that drift from `package.json` scripts | +| [development.md](development.md) | Contributor setup, daily workflow, and a summary of CI; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), check-by-check lists that drift from `package.json` scripts | | Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | -Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type shapes → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. +Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. ## Writing rules @@ -41,8 +41,8 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). -- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, timing, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. -- Write directly: name actors and facts plainly ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability; avoid metaphorical "gate", "vocabulary", and "surface". +- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details. +- Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface". ## Wordcount Budgets @@ -54,7 +54,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the contract still has room, and raise it when content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review governs unbudgeted tiers. +Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room, and raise it when content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review governs unbudgeted tiers. ## The slop checklist diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml index 144d77b8c2..97dd2649f0 100644 --- a/docs/agent-lifecycle.i18n.yaml +++ b/docs/agent-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/agent-lifecycle.md -agent-lifecycle.md: 7e3939b7fd6e7730918e4bc05e5bfcd655422e04 -agent-lifecycle.zh.md: 259ec78431ff0ead61c66e1d8edae4d22f1bcfd0 +agent-lifecycle.md: 4d89e591626454af72de40426e66248f9b6fa404 +agent-lifecycle.zh.md: a54547f486c447fe83fbe6017d016102d66b8612 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 7e3939b7fd..4d89e59162 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -75,8 +75,8 @@ The `assistant/message` event records every successful provider call, including `dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative. -The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch. +The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch. -SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. +SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request construction, steering, continuation, and errors. Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md index 259ec78431..a54547f486 100644 --- a/docs/agent-lifecycle.zh.md +++ b/docs/agent-lifecycle.zh.md @@ -77,8 +77,8 @@ sequenceDiagram `dsh-compact-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。 -以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续边界认领其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。 +以返回的 `agent/pre-step` 决策为准;通过包装 `next()` 的监听器会保留下游消息,除非有意替换这些消息。steering(中途引导)和注入的上下文在后续的认领操作取得其下一步骤批次后,会经过同一 waterfall(瀑布式事件)。 -需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event`;`agent/*` 是用于队列与状态、提示词拦截、请求整形、steering、继续执行和错误处理的实时协调接口。 +需要可回放 transcript(文本记录)数据的 SDK 用户应当消费 `session/event`;`agent/*` 是用于队列与状态、提示词拦截、请求构造、steering、继续执行和错误处理的实时协调接口。 维护模式:英文源文件包含人工维护的 Mermaid 时序图,并由生成器写出;本中文文件作为经评审对侧通过双语配对维护。确切的事件签名位于生成的 Cordis 目录中。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 0d82ebdd37..29bfed3f19 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 1fdbe256afb7e870e953f05bd29923a4e1c7c22b -api-gateway.zh.md: b9e18bf78bb5887725a5278e0b28efb86d17b944 +api-gateway.md: 4b904f24e5755460b6c629e3d2fd43ffc1aefaaf +api-gateway.zh.md: 1e434cbb99450d241d6fde7e570ae1fadf5c0209 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 1fdbe256af..4b904f24e5 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -85,7 +85,7 @@ The `api-remotes` assembly and the `ctx.remote` contract are React-independent; | Build | `@deepseek-ai/dsh-typert-generator` | Strictly analyzes Remote signatures, the type graph, lookups, Contexts, and source locations from the Host `ts.Program`, then generates Host and Host-for-Client artifacts | | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-remotes` | Owns the application Agent/Session identity policy and configures the corresponding TypeRT lookups | -| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates boundaries | +| Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, resolves objects or Contexts, invokes live Cordis services, and validates request and return values | | Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.<namespace>` child Services, mounts generated descriptors as concrete methods, and initiates, validates, and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and the `/api` HTTP bridge | @@ -114,7 +114,7 @@ Business packages expose the Host Loader entry through `./typert` and the Host-f Parameter names in Remote Client declarations come from wire fields, while parameter and return types reference Client-safe types exported by the original business package. The declaration map resolves the generated property behind `ctx.remote.goals.create` back to the Host source method marked with `@Remote`, so editors that support declaration maps can navigate from a Client call to the real implementation instead of stopping at the generated `.d.ts`. -Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build or earliest resolvable runtime boundary fails. +Strict analysis requires a Remote to be a public, non-static instance method with a concrete implementation. The method cannot be generic; parameters must be required, named simple identifiers and cannot use destructuring, default values, rest parameters, or optional parameters. TypeRT generates strict schemas for ordinary JSON-representable types; complex objects such as workspace classes must have a unique `TypeRTLookupMap` declaration. Lookup and Context packages are responsible for both static declaration merges and runtime provider registration; if either side is missing, the build fails or the first call that needs the provider fails. ## Runtime invocation @@ -122,7 +122,7 @@ Remote and API Proxy share the Connection's `/api` route. The Client Remote call The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The TypeRT Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier in the future does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails at the boundary before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. API Remotes owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index b9e18bf78b..1e434cbb99 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -85,7 +85,7 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | 构建 | `@deepseek-ai/dsh-typert-generator` | 从 Host `ts.Program` 严格分析 Remote 签名、类型图、lookup、Context 与源码位置,并生成 Host 和 Host-for-Client 产物 | | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-remotes` | 负责应用的 Agent/Session 身份策略,并配置对应的 TypeRT lookup | -| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service 并校验边界 | +| Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,解析对象或 Context,调用实时 Cordis Service,并校验请求值和返回值 | | Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.<namespace>` 子 Service,把生成的描述符挂成具体方法,并通过 Connection 发起、校验和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与 `/api` HTTP bridge | @@ -114,7 +114,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则引用原业务包导出的 Client-safe 类型。声明 map 把 `ctx.remote.goals.create` 最终解析到的生成属性映射到带 `@Remote` 的 Host 源方法,因此支持 declaration-map 的编辑器可以从 Client 调用跳到真实实现,而不是停在生成的 `.d.ts`。 -严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册,缺少任一侧都会在构建或最早可解析的运行时边界报错。 +严格分析要求 Remote 是公开、非静态、有具体实现的实例方法。方法不能是泛型;参数必须是具名且必填的简单标识符,不能使用解构、默认值、rest 或可选参数。可 JSON 表示的普通类型由 TypeRT 生成严格 schema;工作区 class 等复杂对象必须具有唯一的 `TypeRTLookupMap` 声明。lookup 与 Context 包同时负责静态声明合并和运行时提供方注册;缺少任一侧都会导致构建失败,或者首次调用需要该提供方时失败。 ## 运行时调用 @@ -122,7 +122,7 @@ Remote 与 API Proxy 共用 Connection 的 `/api` 路由。Client Remote 调用 Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。TypeRT Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和 request cancellation,Gateway 只拥有 Remote 数据协议和业务分发。未来替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程界面。 -Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都在进入或离开业务边界时失败。 +Gateway 每次调用都从当前注册表解析描述符和实时 Service,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context provider 解析对象或接收者,最后调用 binding 指向的 Service 方法并校验返回值。缺少 provider、identity 未命中、binding 不一致、参数多缺、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup provider 的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于 provider 挂载;没有 provider 时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复 provider 默认策略。API Remotes 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 22c74a441e..344bac8145 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: ebf05397cb67cea336dd36a4d9416d43b6002d4e -architecture.zh.md: fec9a00484c495b0eed4773f262bb44543e304bd +architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa +architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2 diff --git a/docs/architecture.md b/docs/architecture.md index ebf05397cb..8d1c5a1be3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,7 +122,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Waking input that lands after the abort fires but before convergence runs at the driver's convergence boundary, while a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. The driver processes waking input received after abort starts but before convergence; a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](subsystems/session.md#why-a-turn-ended-turnendreasonmap). @@ -142,7 +142,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw **Model-visible ⟺ logged**: messages entering at `step/start` plus the folded `request/header` reconstruct every request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `request/context` separately records registration-bound provider, model, and capacity metadata when the route changes; it does not participate in request reconstruction or header equality. `dsh-agent-loop/invariant` asserts reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). -Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard and SQLite shares the contract ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). +Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard, and SQLite uses the same checkpoint and batching rules ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)). Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` relies on bounded background persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; the latest title event wins, and it records the source message seqs and whether the user, fallback, or provider supplied it. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). @@ -185,7 +185,7 @@ New behavior attaches to a documented extension point; a loop change updates thi | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | -| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | +| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the event that stops a turn | | Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index fec9a00484..53bf9f54a5 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -122,7 +122,7 @@ idle inject: 适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`;取消和 dispose(资源释放)优先于恢复。在提交请求头之前,轮次信号会取消能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。abort 触发后、收敛前到达的唤醒输入会在 driver 的收敛边界执行,而 `disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久性以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和 dispose(资源释放)优先于恢复。在提交请求头之前,轮次信号会取消能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。driver 会处理在 abort 开始后、收敛前收到的唤醒输入;`disposed` 取消会让该输入保持待处理状态([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久性以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](subsystems/session.md#why-a-turn-ended-turnendreasonmap)。 @@ -142,7 +142,7 @@ idle inject: **模型可见 ⟺ 已记录**:在 `step/start` 进入的消息加上折叠后的 `request/header` 可以重建每个请求。该 header 会标记适配器默认值,使后续提议丢弃这些值并重新解析路由,同时不丢失显式设置。`request/context` 会在路由变化时另行记录与注册项绑定的提供方、模型及容量元数据;它不参与请求重建或 header 相等性判断。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。 -持久性由插件负责。后端会将同步的 `session/event` 通知复制到固定窗口的持久化批次中;`session/flush` 会绕过等待,在请求与顶层工具分发之前执行,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一约定([检查点决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)、[批处理决策](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。 +持久性由插件负责。后端会将同步的 `session/event` 通知复制到固定窗口的持久化批次中;`session/flush` 会绕过等待,在请求与顶层工具分发之前执行,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 使用同样的检查点与批处理规则([检查点决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)、[批处理决策](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。 在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 依赖有界后台持久化与生命周期排空;手动压缩会在操作完成前 flush 其标记对。标题工作绝不延迟响应;最新的标题事件生效,并记录来源消息 seq,以及标题由用户、后备逻辑还是提供方提供。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 @@ -184,8 +184,8 @@ idle inject: | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | | 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 | -| 限制 spawn 出的进程 | 使用 `ctx.sandbox` 后端;消费方在 spawn 前包装 argv | -| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | +| 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | +| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止轮次的事件 | | 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | | Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d8e6851d04..c5cc60a014 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: d5610b6bf6bcb39bfa146ada53ce0734a978b8cd -capability-seams.zh.md: 8a1b38d3600d673a8e0a99941e51d0973101a1c1 +capability-seams.md: 85aee35af0e3de4d7cdbb715bc60c832c023d5ac +capability-seams.zh.md: d3664b649f36b1788c6633497c95d5731ad4a401 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d5610b6bf6..85aee35af0 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -438,6 +438,6 @@ flowchart LR | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | Maintenance mode: hybrid: services are discovered from Cordis declarations; interface/implementation/consumer roles are classified in `scripts/gen-doc-graphs.ts` with a completeness guard. diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8a1b38d360..d3664b649f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -440,6 +440,6 @@ flowchart LR | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`、`directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.httpServer` | `core` | `webserver` | - | `connection`、`modules`、`hmr` | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | 通过增量 dshClient 扫描组合 __DSH_BOOT__ 入口图,提供插件组合包,并通知重建/图变更订阅方。 | -| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow)、[`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎(bash 形态,无具名提供方注册表);通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | +| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow)、[`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | 维护模式:混合模式。服务从 Cordis 声明中发现;接口、实现和消费方角色在 `scripts/gen-doc-graphs.ts` 中分类,并设有完整性守卫。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7319ee9070..09c961ef69 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: bf5bdc275e4611afaa6950078459ea34723a0d53 -config-catalog.zh.md: 0d9711d729364d2b06dbc7859f7c0a255222979f +config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618 +config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df diff --git a/docs/config-catalog.md b/docs/config-catalog.md index bf5bdc275e..51c6ae46ee 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -898,12 +898,12 @@ export type PiAiModelOverride = Omit<PiAiModelProfile, 'id'> * default) or per model (winning over the route). Only the switches pi-ai's * reasoning dispatch reads are offered; the rest of pi-ai's compat surface * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning - * shape in the protocol itself — so resolution rejects a model-level switch + * `OpenAICompletionsCompat` — the other wire protocols define their reasoning + * fields in the protocol itself — so resolution rejects a model-level switch * anywhere else, while a route-level default skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ thinkingFormat?: PiAiThinkingFormat /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ supportsReasoningEffort?: boolean @@ -1161,7 +1161,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -1311,7 +1311,7 @@ Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../pack /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after @@ -1523,7 +1523,7 @@ Requires: `sessions` ```ts config-catalog /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -1559,7 +1559,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:80`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` @@ -1807,7 +1807,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** @@ -1979,7 +1979,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] @@ -2448,7 +2448,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:624`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:625`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d9711d729..dc93f5b4b5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -900,12 +900,12 @@ export type PiAiModelOverride = Omit<PiAiModelProfile, 'id'> * default) or per model (winning over the route). Only the switches pi-ai's * reasoning dispatch reads are offered; the rest of pi-ai's compat surface * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning - * shape in the protocol itself — so resolution rejects a model-level switch + * `OpenAICompletionsCompat` — the other wire protocols define their reasoning + * fields in the protocol itself — so resolution rejects a model-level switch * anywhere else, while a route-level default skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ thinkingFormat?: PiAiThinkingFormat /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ supportsReasoningEffort?: boolean @@ -1163,7 +1163,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -1313,7 +1313,7 @@ export interface Config { /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after @@ -1525,7 +1525,7 @@ export interface Config { ```ts config-catalog /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -1561,7 +1561,7 @@ export enum TelemetryMode { 依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`) -来源:[`packages/session/session-telemetry-otel/src/index.ts:80`](../packages/session/session-telemetry-otel/src/index.ts) +来源:[`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` @@ -1809,7 +1809,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** @@ -1981,7 +1981,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] @@ -2449,7 +2449,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -来源:[`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:617`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index aa582c2bb1..26d61a6041 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 79e8604a47a4bdbb7e2912990a8b1c8be300b3b0 -adding-a-package.zh.md: 5fcdf03f52dfdfe375aeb2b6fd41e50ee406d043 +adding-a-package.md: 9e82e6d00177768a6368d1cd9740afa585543171 +adding-a-package.zh.md: 072df33138a1ead20c497cebd8e4aa960c2d1fc8 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 79e8604a47..9e82e6d001 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -53,7 +53,7 @@ Keep package-specific service API, config, events, extension points, and design #### What the model sees -An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. +The exact data-dependent fields, an anchored generated-catalog link, or an introduction to the verbatim literal below. ##### Verbatim text for this field, when needed @@ -71,10 +71,10 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex ## Known Limitations and Deferred Work -- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. +- **Consumer-visible gap** — exact missing operation or case, its consequence, and any maintainer constraint. ```` -Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the three ordered H4 fields shown above and one prose paragraph under each. Quote stable text owned by the package: system-prompt prose goes in a titled H5 plus `markdown` fence under the field that introduces it—normally `What the model sees`—other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. In `KV Cache effect`, distinguish append-only growth, a stable repeated prefix, replacement of earlier request tokens, and an independent model request, then name the package-owned changes that can invalidate reuse. “Does not invalidate” means the package preserves an already-reusable prefix; provider cache availability and eviction remain outside the package contract. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. +Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the three ordered H4 fields shown above and one prose paragraph under each. Quote stable text owned by the package: system-prompt prose goes in a titled H5 plus `markdown` fence under the field that introduces it—normally `What the model sees`—other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. In `KV Cache effect`, distinguish append-only growth, a stable repeated prefix, replacement of earlier request tokens, and an independent model request, then name the package-owned changes that can invalidate reuse. “Does not invalidate” means the package preserves an already-reusable prefix; provider cache availability and eviction remain outside the package contract. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the required section structure. A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts), followed by a `KV Cache effect` H4 and one non-empty paragraph; a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 5fcdf03f52..072df33138 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -53,7 +53,7 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c #### What the model sees -An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. +The exact data-dependent fields, an anchored generated-catalog link, or an introduction to the verbatim literal below. ##### Verbatim text for this field, when needed @@ -71,10 +71,10 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex ## Known Limitations and Deferred Work -- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. +- **Consumer-visible gap** — exact missing operation or case, its consequence, and any maintainer constraint. ```` -根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包约定。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 +根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包约定。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行所需章节结构。 没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 16439b1732..e232ad4302 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: cb418a9118901cc6572fb17125351bdda922434b -adding-a-tool.zh.md: 22eccec67a3f941976608dc7fc1cc4120e4e1bd1 +adding-a-tool.md: b030d3c3a6b7dd66b6594779345a96af3a895bd8 +adding-a-tool.zh.md: 4272a7a4571782bc213ca29de1a57d51fbd24075 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index cb418a9118..b030d3c3a6 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -56,7 +56,7 @@ The producer supplies synchronous `cancel`, non-rejecting `done` that settles af ## Execution policy and observation -Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap canonical dispatch with a deadline/retry/metrics scope, `tools/post-execute` to replace either presentation content or the canonical value, block, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points). +Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap dispatch with a deadline, retry, or metrics collection, `tools/post-execute` to replace presentation content or the returned value, block the result, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also run inside the tool's executor implementation; the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points) defines each extension point's inputs, order, return values, and failure behavior. ## Code Mode reaches your tool for free @@ -85,7 +85,7 @@ Hard rules (they bite if broken): - **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the UI adapter, not the tool, supplies session context. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs in durable result metadata or the adapter, not the presenter. - **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the adapter adds any fallback framing. -- **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. +- **`defineTool` soft-validates the display path.** Malformed or older logged arguments make the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 22eccec67a..4272a7a457 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -56,7 +56,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 ## 执行策略与观测 -尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为规范分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 替换展示内容或规范值、阻止调用,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略必须阻止调用或替换值。沙箱实现也可以位于工具执行器的能力 seam 之后;确切约定见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。 +尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为分发添加截止时间、重试或指标收集;使用 `tools/post-execute` 替换展示内容或返回值、阻止结果,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略必须阻止调用或替换值。沙箱实现也可以在工具的执行器实现中运行;[`dsh-tools` README](../../packages/core/tools/README.md#extension-points) 定义每个扩展点的输入、顺序、返回值和失败行为。 ## Code Mode 自动触达你的工具 @@ -85,7 +85,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 - **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);会话上下文由 UI 适配器而非工具提供。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下:那属于持久结果元数据或适配器,不属于展示器。 - **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由适配器按需添加回退格式。 -- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 +- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的参数会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index b8416c384f..4f5b8c3c49 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md -adding-a-vendored-package.md: b85d74a3a09b27254883b88cb8e6587e32ed811c -adding-a-vendored-package.zh.md: 2927837a28d1e7b593090d581522f69f84504806 +adding-a-vendored-package.md: 724d89c1c7cd728f7123a6975b5500cd40815851 +adding-a-vendored-package.zh.md: d16ec1056431a4ac1c02d50a5ef0f0a64b67ca6d diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index b85d74a3a0..724d89c1c7 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -9,7 +9,7 @@ When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-h ``` vendor/<dir>/ package.json # from upstream; set "private": true, keep name/exports/type - tsconfig.json # extends ../../tsconfig.base.json (see shape below) + tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them ``` @@ -31,7 +31,7 @@ vendor/<dir>/ `package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). -Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build-shape divergence from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. +Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build difference from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. ## 2. Register it in the root configs @@ -42,7 +42,7 @@ Local relative imports/exports in vendored TypeScript source use explicit `.ts` | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor/<dir>/tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`. A per-package `vendor/<dir>/tsdown.config.ts` is needed ONLY if the build configuration differs from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. ## 3. Mind the manifest guard diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index 2927837a28..d16ec10564 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -9,7 +9,7 @@ ``` vendor/<dir>/ package.json # from upstream; set "private": true, keep name/exports/type - tsconfig.json # extends ../../tsconfig.base.json (see shape below) + tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them ``` @@ -31,7 +31,7 @@ vendor/<dir>/ `package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 -vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地的构建形态与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 +vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地构建与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 ## 2. 在根配置中注册 @@ -42,7 +42,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显 | `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | | `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | -以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 +以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`.oxlintrc.json`。只有当构建配置与根默认值不同时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 ## 3. 注意 manifest 守卫 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 6e83450ec3..ac600e8c6d 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: aba220ec6dc3cf3d0f99edd0e47ffa6069499b47 -extension-cookbook.zh.md: 34e8fc0fa3d2d57136616d66cfb4f3f6de20605e +extension-cookbook.md: 95ba269a5d62e14cfde487d5a3aaca5db493657e +extension-cookbook.zh.md: e3fbe09f1ec09568e3b259aee361d33ba3e62140 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index aba220ec6d..95ba269a5d 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -2,11 +2,11 @@ English | [中文](extension-cookbook.zh.md) -Reference shapes for the harness extension surface. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. +Reference patterns for harness extensions. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-point map. ## A tool plugin -A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` args, result shaping, the `run_in_background` pattern) lives in [adding-a-tool.md](adding-a-tool.md) — that guide is the source of truth for the tool shape. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed sugar for first-party tools. +A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` arguments, result construction, the `run_in_background` pattern) lives in [adding-a-tool.md](adding-a-tool.md) — that guide is the source of truth for tool definitions. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed helper for first-party tools. ## A hook plugin (permission-gate example) @@ -64,7 +64,7 @@ export function apply(ctx: Context) { A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, and maps protocol requests to `followup()` or `cancel()`. A low-level prompt request returns its durable enqueue receipt; it does not acquire a result by correlating `MessageId` with `turn/end`. Publish whole-agent status separately. An automation method may wait from its receipt through the next idle and summarize that explicitly owned interval, while a UI normally keeps observing the open-ended event stream. Tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. -[`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) owns the exact method and lifecycle contract. +[`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) defines the exact methods, event order, and lifecycle contract. ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 34e8fc0fa3..e3fbe09f1e 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -2,11 +2,11 @@ [English](extension-cookbook.md) | 中文 -harness 扩展表面的参考形态。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展点映射由[架构文档](../architecture.md)负责。 +harness 扩展的参考模式。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展点映射由[架构文档](../architecture.md)负责。 ## 工具插件 -工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果塑形、`run_in_background` 模式)见 [adding-a-tool.md](adding-a-tool.md)——该指南是工具形态的真源。`ctx.tools.register()` 也直接接受原始 JSON Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是为第一方工具提供的类型化语法糖。 +工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果构造、`run_in_background` 模式)见 [adding-a-tool.md](adding-a-tool.md)——该指南是工具定义的真源。`ctx.tools.register()` 也直接接受原始 JSON Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是第一方工具使用的类型化辅助函数。 ## 钩子插件(以权限门禁为例) @@ -64,7 +64,7 @@ export function apply(ctx: Context) { *协议驱动*将协议对端接入 `ctx.agents`;它可以服务于 UI 或自动化客户端。stdio 驱动拥有 stdout,通过工厂创建或恢复 agent(智能体),并将协议请求映射为 `followup()` 或 `cancel()`。底层提示词请求返回其持久入队回执;它不会通过关联 `MessageId` 与 `turn/end` 获得结果。整个 agent 的状态应单独发布。自动化方法可以从回执等待到下一次 idle,并概括这一显式拥有的区间;UI 通常则会持续观察开放式事件流。通过 `AgentHandle.dispose()` 拆除 agent,以使 dispose(资源释放)达到完全停稳。 -[`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 拥有精确的方法和生命周期约定。 +[`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 定义确切的方法、事件顺序和生命周期约定。 ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml index 331373fb1e..8ceb56b26b 100644 --- a/docs/cookbook/maintaining-dsh-code-review.i18n.yaml +++ b/docs/cookbook/maintaining-dsh-code-review.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/maintaining-dsh-code-review.md -maintaining-dsh-code-review.md: c8517054434f4b090c67455cda0a992c2c3173ee -maintaining-dsh-code-review.zh.md: 3d26d540a41661aa45b41d6738118f36d904ccf3 +maintaining-dsh-code-review.md: a8c2a66c065aaec5c03f0ab6965377d1d1eb14bd +maintaining-dsh-code-review.zh.md: c72323c4ab0c31fa30aa3ef0e8ea41b129ad0c1d diff --git a/docs/cookbook/maintaining-dsh-code-review.md b/docs/cookbook/maintaining-dsh-code-review.md index c851705443..a8c2a66c06 100644 --- a/docs/cookbook/maintaining-dsh-code-review.md +++ b/docs/cookbook/maintaining-dsh-code-review.md @@ -20,7 +20,7 @@ Each run stores its artifacts on the operator's machine. The saved diff, candida When a run produces a candidate, a macOS notification arrives with a `dsh-code-review-promote <timestamp>` hint. -1. **Read the diff on its own merits.** Do not defer to "the reviewers approved" — the maintainer contract is that the operator is the final judgment. Look for checklist bloat, historical prose, unsupported extrapolation from a single incident, and duplicated coverage with existing skill or authoritative-doc content. +1. **Read the diff on its own merits.** Do not defer to "the reviewers approved"; the maintainer contract is that the operator makes the final decision. Look for checklist bloat, historical prose, unsupported extrapolation from a single incident, and duplicated coverage with existing skill or authoritative-doc content. ```sh ls ~/dsh-code-review-outputs/ # every candidate ever produced diff --git a/docs/cookbook/maintaining-dsh-code-review.zh.md b/docs/cookbook/maintaining-dsh-code-review.zh.md index 3d26d540a4..c72323c4ab 100644 --- a/docs/cookbook/maintaining-dsh-code-review.zh.md +++ b/docs/cookbook/maintaining-dsh-code-review.zh.md @@ -20,7 +20,7 @@ 某次运行产出候选版本时,macOS 会发出一条带 `dsh-code-review-promote <timestamp>` 提示的通知。 -1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受:维护者约定规定最终判断由操作员作出。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 +1. **根据 diff 本身作出判断。** 不要因为「评审者已经批准」就直接接受;维护者约定规定由操作员作出最终决定。检查清单是否膨胀、是否有历史叙述、是否根据单次事件作出无依据的外推,以及是否与现有 skill 或权威文档重复。 ```sh ls ~/dsh-code-review-outputs/ # every candidate ever produced diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 9e1143231e..12177e3d35 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: 4bcb2c9979994ca70f92031cbdc5dd22df9c1977 +cordis-primer.md: c95909a4a1deab9407efedbb990ef13be6e43a16 cordis-primer.zh.md: a18b8b37af19a610b71babbe5e67f96bb09e81b1 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 4bcb2c9979..c95909a4a1 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -23,7 +23,7 @@ Every event can have one of the following dispatch mode and can only be dispatch | `parallel` | Yes | all listeners observe the event in parallel | No | | `serial` | Yes | listeners observe in registration order | Yes | -The mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. +The dispatch mode is part of the event's public contract. New harness events document it with an `@mode` tag so the generated catalog can check declarations against dispatch sites. ## Cordis Waterfall Semantics diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 1fe3585ba1..9bf649ab29 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 4359dfe4883f12e9cb242cf3009827fd7864768c -01-first-plugin.zh.md: 9965f4ddb75fa338ba7fd9d564bd4a32fced7b93 +01-first-plugin.md: 260026329443f9a5b8860d11a6527dbd687eb44c +01-first-plugin.zh.md: 69dedb898c7ea29f99233f07126cd413fa0ddbe2 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 4359dfe488..2600263294 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -52,7 +52,7 @@ There is no framework bootstrap code in your file: a plugin describes what it co ## The two other plugin shapes -A function is the most common shape, but Cordis accepts three: +A function is the most common form, but Cordis accepts three: ```ts import { Service, type Context } from 'cordis' diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 9965f4ddb7..69dedb898c 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -52,7 +52,7 @@ hello from my first plugin ## 其他两种插件形态 -函数是最常见的形态,但 Cordis 接受三种形态: +函数是最常见的形式,但 Cordis 接受三种形式: ```ts import { Service, type Context } from 'cordis' diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index fa810d635f..719e949ffe 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: 7a0bb6f8c736bf31d655a7763cfb7039c343d1a2 -index.zh.md: e6f6dc0cccef3f44273655b98b695bdc4632e95a +index.md: 307c12854b3075cfd4dd5ea8a19806c58b4e998d +index.zh.md: a0107b7d15272e6ef8d526b9c0e03a99275644d6 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 7a0bb6f8c7..307c12854b 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -53,6 +53,6 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: - **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. - **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. -Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. +Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema<Config>` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index e6f6dc0ccc..a0107b7d15 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -53,6 +53,6 @@ node --import tsx ../../vendor/cordis/bin.js - **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 - **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 -第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema<Config>` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 +第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema<Config>` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 68f62583c2..18b28ca58c 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/defensive-patterns.md -defensive-patterns.md: afb462e120892eafe676d8b7feef273c2d0e42df -defensive-patterns.zh.md: ab5f689d47f89b6930b749824c69325490bd4586 +defensive-patterns.md: 368c9876f1a4e7042b003f6acfb30af3b2daf402 +defensive-patterns.zh.md: c7d4c1bf37ef17947913ac4011624d04ffd8c1a3 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index afb462e120..368c9876f1 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -10,7 +10,7 @@ A result can be several things at once — a process can time out AND exit 0 bec ## Honor public contracts on BOTH sides -When an implementation boundary receives several representations of one outcome, normalize them before crossing the public contract. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. +When an implementation receives several representations of one outcome, normalize them before returning through the public API. `LlmAdapter.stream()` implementations may throw or emit `finish {kind:'error'|'aborted'}`, but `LlmService.stream()` exposes model-request failures only as terminal finish chunks; middleware and consumer defects remain thrown. This keeps consumers from guessing whether a caught exception came from the provider, a wrapper, chunk logging, or their own assembly. Document the normalized contract where the type is defined; exercise every source form through the real consumer. ## Async state is not synchronous state @@ -20,7 +20,7 @@ When an implementation boundary receives several representations of one outcome, A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. -## Contain callback exceptions at the boundary +## Contain callback exceptions in the dispatcher A user-supplied listener that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; one bad subscriber never breaks core lifecycle. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index ab5f689d47..c7d4c1bf37 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -10,7 +10,7 @@ ## 公共约定两侧都要遵守 -当一个实现边界接收到同一结果的多种表示时,应在跨越公共约定前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止 finish chunk 暴露模型请求失败;middleware 与消费方缺陷仍会抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化约定;通过真实消费方覆盖每种来源形式。 +当一个实现收到同一结果的多种表示时,应在通过公共 API 返回前将其规范化。`LlmAdapter.stream()` 的实现可以抛出异常或发出 `finish {kind:'error'|'aborted'}`,但 `LlmService.stream()` 只会通过终止型 finish 分片暴露模型请求失败;middleware 缺陷与消费方缺陷仍会以异常形式抛出。这使消费方不必猜测捕获的异常究竟来自提供方、包装层、chunk 日志记录还是自身组装逻辑。请在类型定义处记录规范化后的约定;通过真实消费方覆盖每种来源形式。 ## 异步状态不是同步状态 @@ -20,7 +20,7 @@ 如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器和通知注册表,使迟到的完成事件保持静默。 -## 在边界处隔离回调异常 +## 在分发器中隔离回调异常 用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 84cff8fa96..da29debe08 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 8b6c148d87fdd6288695b0029b9dfcc0139d2144 -development.zh.md: 55ca013cc0dd901500e340a7447057de27665f61 +development.md: 8e565f21c6e2ede7dab7dbda3c4b18b77ce0920f +development.zh.md: d9c0fbfbb663334b8f7e2ca11d4e8d9a0652c22e diff --git a/docs/development.md b/docs/development.md index 8b6c148d87..8e565f21c6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ English | [中文](development.zh.md) -The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts. +The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts. ## Setup tutorial @@ -51,7 +51,7 @@ The repository uses isolated Host and Client aggregates. An ordinary package is | `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes | | `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes | | `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No | -| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | +| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No | Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow: @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary. +`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order. The root build follows the generated dependency order: @@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil TypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. -Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. +Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. @@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional ### Git integrations -The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary. +The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts. The installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states. @@ -156,10 +156,10 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ### Documenting types verbatim (`ts type-equiv`) -The [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. diff --git a/docs/development.zh.md b/docs/development.zh.md index 55ca013cc0..d9c0fbfbb6 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -2,7 +2,7 @@ [English](development.md) | 中文 -搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。 +搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。 ## 搭建教程 @@ -51,7 +51,7 @@ pnpm run typecheck | `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 | | `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 | | `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 | -| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | +| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 | Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律: @@ -59,7 +59,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。 +`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。 根构建按生成依赖排序: @@ -75,7 +75,7 @@ pnpm run build:web TypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 -静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 +静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 @@ -100,7 +100,7 @@ DEEPSEEK_BASE_URL=https://... # optional ### Git 集成 -当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。 +当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。 安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。 @@ -156,10 +156,10 @@ pnpm run demo:acp ### 逐字记录类型(`ts type-equiv`) -[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 3bac30f742..a2caf7b784 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 4b7a8794808cc02daac7031f4981a765c97ca81a -event-producer-consumer.zh.md: 976f41d7798e9182b60366d77e546d79c4a66a13 +event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 +event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4b7a879480..b78171ce51 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 976f41d779..c044385bf9 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -46,12 +46,12 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/graph-atlas.i18n.yaml b/docs/graph-atlas.i18n.yaml index 0cd73d4f39..09b1b58178 100644 --- a/docs/graph-atlas.i18n.yaml +++ b/docs/graph-atlas.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/graph-atlas.md -graph-atlas.md: 5b831520fb4e1c5d49d83738ba979ce04ce4f69f -graph-atlas.zh.md: 1f9a30124284549248bbb21f926772d5cdc2598a +graph-atlas.md: bf2aeba1210709cdda68e0ea7d611528f3191744 +graph-atlas.zh.md: 780e5295f74f10ee4fe762e280e5c1c820c481c4 diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 5b831520fb..bf2aeba121 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -3,7 +3,7 @@ # Documentation Graph Index -These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md). +These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md). The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md). diff --git a/docs/graph-atlas.zh.md b/docs/graph-atlas.zh.md index 1f9a301242..780e5295f7 100644 --- a/docs/graph-atlas.zh.md +++ b/docs/graph-atlas.zh.md @@ -5,7 +5,7 @@ [English](graph-atlas.md) | 中文 -这些图构成生成目录之上的关系层。你可以借助它们了解包拓扑、能力 seam、事件流、面向模型的工具、应用组合以及运行时生命周期路径。精确签名和类型结构仍以[子系统页面](subsystems/core.md)(类型和生成的 `cordis-surface` 区域)及[工具目录](tool-catalog.md)为准。 +这些图展示生成目录未包含的关系。可以用它们查找包之间的关系、能力 seam、事件流、面向模型的工具、应用组合和运行时生命周期路径。精确签名和类型定义仍以[子系统页面](subsystems/core.md)(类型和生成的 `cordis-surface` 区域)及[工具目录](tool-catalog.md)为准。 本索引背后的流程决策记录在[文档图 Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md)中。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index d84f04b02a..45e4077203 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: 042fe71e796340c5c653e1f671c53a95a8496a38 -README.zh.md: d2d3b84e98cf4b761ae0174459b91236a71fb187 +README.md: af6a35294bc23adcd6214747f78a28a69ae443d1 +README.zh.md: 74cb98932460d014dab26d3b48cd81142e0f7bf8 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 042fe71e79..af6a35294b 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). +This repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). ## The pairing contract @@ -17,7 +17,7 @@ This repo's documentation is read by people and agents both inside and outside t Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). - When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. + When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. - **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). @@ -37,7 +37,7 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. -The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. +The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. ## Scope and exclusions diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index d2d3b84e98..74cb989324 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 ## 配对约定 @@ -17,7 +17,7 @@ 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 - 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何无法确定的情形都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 + 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 - **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 - **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 @@ -37,7 +37,7 @@ 这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 -把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 +门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 ## 范围与排除 diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 1b8a0e3f1a..eb8a4b673b 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -14,9 +14,9 @@ 依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-spine-demo`,它的职责是组装整套实体主干。 -> This document covers **behavior**; type shapes live in [subsystems/](../subsystems/core.md), the per-event/service reference in the generated regions of [subsystems/](../subsystems/core.md), per-package contracts in the package READMEs ([map](../../packages/README.md)). +> This document covers **behavior**; type definitions live in [subsystems/](../subsystems/core.md), the per-event/service reference lives in the generated regions of [subsystems/](../subsystems/core.md), and package contracts in the package READMEs state each package's required configuration and behavior ([map](../../packages/README.md)). -本文档描述整体行为逻辑;类型定义存放于 [subsystems/](../subsystems/core.md);各类事件、服务的详细参考见 [subsystems/](../subsystems/core.md) 中的生成区块;各包(package)的对外约定写在相应的 README 中([索引](../../packages/README.md))。 +本文档描述整体行为逻辑;类型定义存放于 [subsystems/](../subsystems/core.md);各类事件、服务的详细参考见 [subsystems/](../subsystems/core.md) 中的生成区块;相应的 README 说明每个包(package)要求的配置和行为([索引](../../packages/README.md))。 ## ② 防御模式规则 @@ -46,9 +46,9 @@ 自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。 -> **Prefer the real implementation over a mock** — Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. +> **Prefer the real implementation over a mock** — Mock only genuinely expensive or non-deterministic dependencies (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green. -**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的边界模块做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 +**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的依赖做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。 ## ④ 机制描述 @@ -58,9 +58,9 @@ ## ⑤ 政策声明 -> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and shape; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. +> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. -门禁的边界很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。门禁无法判断两种语言是否真正表达了相同的意思;这部分约定要由评审人把关。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。 +门禁的限制很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且 Markdown 结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。评审人必须检查两种语言是否真正表达了相同的意思。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。 ## ⑥ Agent Note 论证 @@ -84,4 +84,4 @@ - 长段按语义单元拆段,一段一件事;名词短语展开为动词句。 - 母语重写不等于删减:原文每个语义成分都要落地。 - 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock、LLM 保留英文,cancellation 译「取消」)。 -- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写——这是行文规则的硬边界,Pass 2 逐句核验的重点。 +- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写;Pass 2 必须逐句核验。 diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index d8c6d95f73..b4ade80981 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -70,7 +70,7 @@ A lower-priority rule may refine but never override a higher-priority requiremen - The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it. - Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions. - Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction. -- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them. +- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning. - Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`. - Keep the author's register: concise stays concise, detailed stays detailed. @@ -127,7 +127,7 @@ A terminology table is provided below. Follow it strictly: ## Output Format -Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence. +Return exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence. The outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping. @@ -152,10 +152,10 @@ The outer section tags are framing. If Markdown inside any section body contains ## Self-Review Instructions -After writing `<translation>`, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. +After writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections. **Structure** -- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source? +- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source? - Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source? - Are inline code spans and machine-readable tokens verbatim? - Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one? @@ -169,7 +169,7 @@ After writing `<translation>`, verify it in two directions. First re-read it in **Tone & Style** - Does every sentence read as if originally written by a native technical author? -- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing? +- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing? - Are actors explicit where the target language needs them, without inventing responsibility? **Sentence Structure** @@ -228,9 +228,9 @@ Below are representative examples of common problems and their corrections. Foll - Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。` ### Overly literal → Meaningful rendering -- Source: `awkward phrasing is easier to hear without the source anchoring you` -- Bad: `没有源文锚着,别扭的表述更容易被听出来` -- Good: `不对照原文时,更容易察觉别扭的表达` +- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source` +- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意` +- Good: `不对照原文阅读译文时,更容易察觉别扭的表达` ### Terminology — do not translate what should be kept in English - Source: `typed service seams, and explicit extension points` diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7a8f73a488..ee0b5cbdd6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: f1dd0f6635bbb2ed2bbf679fdab2664cef08906d -persistence-catalog.zh.md: 7a0f66b5622fbc9527947019da442b21a1b67b9a +persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d +persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f1dd0f6635..f44569d3ba 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -474,7 +474,7 @@ Source: [`packages/interaction/permission/src/index.ts:50`](../packages/interact 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7a0f66b562..21ed29a3da 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -476,7 +476,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml index e538f244b9..63484a6ba0 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml +++ b/docs/postmortem/0001-acp-default-export-drops-inject.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0001-acp-default-export-drops-inject.md -0001-acp-default-export-drops-inject.md: 2d36f24fa54814e39345d7fe68792023c2cf0194 -0001-acp-default-export-drops-inject.zh.md: 6ae7d45f58e09f205a5653f7c6d306014d4d393e +0001-acp-default-export-drops-inject.md: f8474bde0b81b24573f813d9a0fb017962751f49 +0001-acp-default-export-drops-inject.zh.md: 1e64f123d1dd0b5e7e81d3a8c4a5e6f77e6411ff diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 2d36f24fa5..f8474bde0b 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -18,7 +18,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Timeline -- The bridge (RFC 010) landed with a full unit suite (codec, in-memory transport, property-based protocol-shape, failure paths, HMR), a key-gated real-API e2e, and a no-key stdout-purity e2e. All green, 100% coverage. +- The bridge (RFC 010) landed with a full unit suite for the codec, in-memory transport, generated protocol messages, failure paths, and HMR; a key-gated real-API e2e; and a no-key stdout-purity e2e. All green, 100% coverage. - A real Zed session immediately failed on `session/new` with `cannot get property "agents" without inject`. - Investigation initially pursued a Cordis "traceable/shadow" theory (plausible, and the mechanism is real — see Bug #2), then instrumented the actual fiber walk in vendored `reflect.ts` and ran the real subprocess. The trace showed the throw at `apply()` line 179 *at plugin load time*, on the ROOT fiber with no shadow — falsifying the shadow theory for `session/new`. - Root cause #1 found: a stray `export default apply`. Removing it fixed `session/new`. @@ -26,7 +26,7 @@ The ACP server could not create or load a single session — the two RPCs an edi ## Root cause #1 — `export default apply` drops the plugin's `inject` (broke `session/new`) -`packages/acp/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports — the same shape as every other plugin in the repo (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: +`packages/acp/acp/src/index.ts` is a *namespace plugin*: it exports `name`, `inject`, `Config`, and `apply` as separate named exports, as every other plugin in the repo does (`invariants`, `llm-deepseek`, `tool-bash`, `tui`, …). But it *also* ended with one extra line no other plugin had: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md index 6ae7d45f58..1e64f123d1 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.zh.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.zh.md @@ -18,7 +18,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 时间线 -- bridge(RFC 010)落地时附带完整的单元测试套件(codec、内存传输、基于属性的协议形状测试、失败路径、HMR(热模块替换))、一个需要 key 的真实 API e2e 测试,以及一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 +- bridge(RFC 010)落地时有一套完整的单元测试,覆盖 codec、内存传输、生成的协议消息、失败路径和 HMR(热模块替换);另有一个需要 key 的真实 API e2e 测试和一个无需 key 的 stdout 纯净性 e2e 测试。全部绿色,100% 覆盖率。 - 真实 Zed 会话在 `session/new` 上立即失败,报错 `cannot get property "agents" without inject`。 - 调查最初追踪了一个 Cordis「traceable/shadow」理论(看似合理,且该机制确实存在——见 Bug #2),随后在 vendor 目录中的 `reflect.ts` 里对实际 fiber 遍历做了插桩,并运行了真实子进程。跟踪结果显示,异常在 `apply()` 第 179 行、*插件加载时*抛出,位于 ROOT fiber 且没有 shadow——推翻了 shadow 理论对 `session/new` 的解释。 - 找到根因 #1:一行多余的 `export default apply`。删除后 `session/new` 修复。 @@ -26,7 +26,7 @@ ACP 服务器无法创建或加载任何一个会话——而这正是编辑器 ## 根因 #1——`export default apply` 丢弃了插件的 `inject`(导致 `session/new` 崩溃) -`packages/acp/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出——与仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)形状相同。但它*还*多了一行其他插件都没有的代码: +`packages/acp/acp/src/index.ts` 是一个*命名空间插件*:它将 `name`、`inject`、`Config` 和 `apply` 作为独立的命名导出,仓库中其他所有插件(`invariants`、`llm-deepseek`、`tool-bash`、`tui` 等)也是如此。但它*还*多了一行其他插件都没有的代码: ```ts ignore-check export const name = 'acp' diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml index b10d882b59..e1e354fc6a 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0002-js-expression-disabled-filesystem-tools.md -0002-js-expression-disabled-filesystem-tools.md: 30ff9d920821a8d55c4bea5f120f1aeeca6634b3 -0002-js-expression-disabled-filesystem-tools.zh.md: 3c18a48d3b7e925a6e75c2d3edb3ec642e72e1b3 +0002-js-expression-disabled-filesystem-tools.md: b2bd37ff2b5a6d01c585514dd83f7fa6604f8945 +0002-js-expression-disabled-filesystem-tools.zh.md: 7a26f8456c13ad22535cd04fdec061dccd2ed85d diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md index 30ff9d9208..b2bd37ff2b 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md @@ -29,7 +29,7 @@ The live confined default did not gain unintended filesystem access. A naive int ## Root cause -The implementation assumed `!!js` applied to an entire Loader entry. Its actual boundary is narrower: `Entry._resolveConfig()` interpolates only `entry.options.config`; `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. +The implementation assumed `!!js` applied to an entire Loader entry. It applies only to `entry.options.config`: `Entry._resolveConfig()` interpolates that field, while `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. The snapshot framework treated any deterministic transcript as valid behavior. Header pins verified the composed tool schemas, but the filesystem scenarios shared a pin from the default composition and therefore did not independently prove that their required tools were registered. Refresh rewrote the expected stdout and session logs before any semantic assertion rejected missing tools. @@ -42,6 +42,6 @@ The snapshot framework treated any deterministic transcript as valid behavior. H ## Lessons -- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify interpolation boundaries. +- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify exactly which fields are interpolated. - A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the expected output. - Permission controls must describe only the capabilities they actually govern. Composition-time filesystem access cannot follow a runtime bash-only preset safely. diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md index 3c18a48d3b..7a26f8456c 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.zh.md @@ -29,7 +29,7 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 根因 -实现时假设 `!!js` 适用于整个 Loader 配置项。其实际边界更窄:`Entry._resolveConfig()` 仅对 `entry.options.config` 进行插值;`Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 +实现时假设 `!!js` 适用于整个 Loader 配置项。实际只有 `entry.options.config` 使用它:`Entry._resolveConfig()` 对该字段进行插值,而 `Entry.disabled` 直接测试 `entry.options.disabled`,不经过插值。YAML 标签在语法上合法,因此加载过程不产生任何诊断信息。 快照框架将任何确定性的 transcript(文本记录)视为有效行为。Header pin 验证了组合后的工具 schema,但文件系统场景共享来自默认组合的 pin,因此未独立证明其所需工具已注册。刷新在任何语义断言拒绝缺失工具之前,就已重写了预期的 stdout 和会话日志。 @@ -42,6 +42,6 @@ Cordis Include 将每个 `!!js` 标量解析为一个表达式对象。Loader ## 教训 -- 语法上被接受的配置值不一定在该位置被求值;应记录并验证插值边界。 +- 语法上被接受的配置值不一定在该位置被求值;应记录并验证具体对哪些字段进行插值。 - 快照刷新是 fixture 的生产过程,不是正确性审查。诸如已注册工具缺失这类语义上不可能的结果,需要独立于预期输出的断言。 - 权限控制只应描述其实际管辖的能力。组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml index ed77fc2f2e..4272783be3 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/postmortem/0003-web-agent-gui-feedback-loop.md -0003-web-agent-gui-feedback-loop.md: 13d13a607babfe7f5ddfdb6773c94f973bbef0db -0003-web-agent-gui-feedback-loop.zh.md: 44d4febc30344135932aad2394b3054587feca1d +0003-web-agent-gui-feedback-loop.md: 0d8c07d9aca3305ea6bc85134e8578ae1bd8f387 +0003-web-agent-gui-feedback-loop.zh.md: 07faa3a302d1b124efcbdc57446995aefeb8bd87 diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.md index 13d13a607b..0d8c07d9ac 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.md +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.md @@ -31,7 +31,7 @@ No change in this investigation restarted or modified the read-only 3081 and 308 ## Root cause -The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly represented the user's selected Workspace, but the model mistook that project boundary for the application boundary. No durable contract related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. +The Web assembly had no model-visible identity for the current GUI, canonical URL, or runtime mode. The session cwd correctly identified the user's selected Workspace, but the model treated that project directory as the application directory. No durable record related the GUI source checkout, built artifacts, serving process, target origin, and browser acceptance. The wrong startup path looked legitimate because bare Vite returned HTTP 200. `window.__DSH_BOOT__` is injected only by the full host, so transport readiness did not imply application readiness. The first regression test repeated this mistake in another form: a timeout killed Vite and satisfied a nonzero-exit assertion. Live reproduction exposed that false positive. diff --git a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md index 44d4febc30..07faa3a302 100644 --- a/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md +++ b/docs/postmortem/0003-web-agent-gui-feedback-loop.zh.md @@ -31,7 +31,7 @@ Web agent 修改了 GUI 源码,却不知道由哪个 URL 和进程承载当前 ## 根因 -Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确表示了用户选择的 Workspace,但模型误把这个项目边界当成了应用边界。系统也没有持久约定将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 +Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式的身份信息。会话 cwd 正确标识了用户选择的 Workspace,但模型把这个项目目录当成了应用目录。系统也没有持久记录将 GUI 源码检出目录、构建产物、服务进程、目标 origin 和浏览器验收关联起来。 裸 Vite 返回 HTTP 200,使错误的启动路径看似合理。`window.__DSH_BOOT__` 只由完整宿主注入,因此传输层就绪不代表应用已就绪。首个回归测试以另一种方式重复了同样的错误:超时机制终止 Vite 后,非零退出断言仍会通过。真实复现暴露了这一误报。 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 99c0f514c4..445d2b5a45 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: b1b57466feeee7625b0b5de78e5f9ff220ddef32 -README.zh.md: 492051f013def0f196902d1105f84a5644057f3a +README.md: 7d66cfcf66ffb0bed9430892308934c1f10982f4 +README.zh.md: 90e2b28b15870387539500568bb85b525db63ef6 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index b1b57466fe..7d66cfcf66 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -6,8 +6,8 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | Page | Owns | |---|---| -| [core.md](core.md) | the `packages/core` control spine: the package-by-package loop map, agent creation and ownership (`AgentHandle`), the `Agent` handle with its delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) | -| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation vocabulary — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` provider contract | +| [core.md](core.md) | how `packages/core` controls the agent loop: the package-by-package loop description, agent creation and ownership (`AgentHandle`), the `Agent` handle's delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) | +| [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation types — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol and adapter contract, `BlockAssembler`, and the `LlmAdapter` provider contract | | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions | | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, TypeRT registries, and the Host Gateway/Client API boundaries | @@ -23,7 +23,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | -| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | +| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts | | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | @@ -38,7 +38,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -| [tasks.md](tasks.md) | the background-task runtime: branded `TaskId`s, the producer contract, consumer views, `ctx.tasks` service behavior | +| [tasks.md](tasks.md) | the background-task runtime: branded `TaskId`s, the producer contract, consumer views, and `ctx.tasks` service behavior | | [permission.md](permission.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | @@ -47,6 +47,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [workspace.md](workspace.md) | the workspace registry: `Workspace`/`WorkspaceId`, registration and resolution, the session `cwd` relationship | | [client-modules.md](client-modules.md) | the web plugin table: `dshClient` declarations, `WebBootGraph` wire composition, the bundle route and index tap | | [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed | -| [telemetry.md](telemetry.md) | the session-telemetry capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, the `telemetry/record` redact waterfall | +| [telemetry.md](telemetry.md) | the outbound session-reporting capability seam: `TelemetryRecord`/`TelemetrySeverity`, the `TelemetryBackend` contract, and the `telemetry/record` redact waterfall | > Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis surface** section. diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 492051f013..90e2b28b15 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -6,8 +6,8 @@ | 页面 | 负责内容 | |---|---| -| [core.md](core.md) | `packages/core` 控制主干:逐包循环地图、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄及其投递/取消/拦截约定,以及全仓通用类型模式(`…Map → 派生联合`、品牌化 id) | -| [llm-streaming.md](llm-streaming.md) | `packages/llm` 的对话词汇——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` 协议格式(wire format)+ 适配器约定(adapter contract)、`BlockAssembler`、`LlmAdapter` 提供方约定 | +| [core.md](core.md) | `packages/core` 如何控制 agent loop:逐包的循环说明、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄的投递/取消/拦截约定,以及全仓通用类型模式(`…Map → 派生联合`、品牌化 id) | +| [llm-streaming.md](llm-streaming.md) | `packages/llm` 的对话类型——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` wire protocol 和适配器约定(adapter contract)、`BlockAssembler`,以及 `LlmAdapter` 提供方约定 | | [token-meter.md](token-meter.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 | | [scope.md](scope.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [typert.md](typert.md) | 远程调用描述符、lookup/Context 声明、TypeRT 注册表,以及 Host Gateway/Client API 边界 | @@ -23,7 +23,7 @@ | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | | [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | -| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 约定 | +| [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | | [attachment.md](attachment.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | | [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | @@ -38,7 +38,7 @@ | [web.md](web.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | | [spill.md](spill.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | -| [tasks.md](tasks.md) | 后台任务运行时:品牌化 `TaskId`、producer 约定、consumer 视图、`ctx.tasks` 服务行为 | +| [tasks.md](tasks.md) | 后台任务运行时:品牌化 `TaskId`、producer 约定、consumer 视图和 `ctx.tasks` 服务行为 | | [permission.md](permission.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | @@ -47,6 +47,6 @@ | [workspace.md](workspace.md) | 工作区注册表:`Workspace`/`WorkspaceId`、注册与解析、与会话 `cwd` 的关系 | | [client-modules.md](client-modules.md) | Web 插件表:`dshClient` 声明、`WebBootGraph` 线上组合、bundle 路由与 index 转换 | | [session-projection.md](session-projection.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 | -| [telemetry.md](telemetry.md) | 会话遥测能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定、`telemetry/record` 脱敏 waterfall | +| [telemetry.md](telemetry.md) | 对外会话上报能力 seam:`TelemetryRecord`/`TelemetrySeverity`、`TelemetryBackend` 约定和 `telemetry/record` 脱敏 waterfall | > 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis surface** 小节。 diff --git a/docs/subsystems/bash.i18n.yaml b/docs/subsystems/bash.i18n.yaml index dbfc52bf6e..89b7a2feb6 100644 --- a/docs/subsystems/bash.i18n.yaml +++ b/docs/subsystems/bash.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/bash.md -bash.md: d2797c1e3ff73fe8ecb5a053ed4a1d13b13298dd -bash.zh.md: b38a4f332978e94d2945f5fa49b38e8d7df8c9ca +bash.md: b7e7c25ac4e49c34e186ec63f2d84411d71631fc +bash.zh.md: 409a00f8e533f84852d56c1074319a8f0425fb37 diff --git a/docs/subsystems/bash.md b/docs/subsystems/bash.md index d2797c1e3f..b7e7c25ac4 100644 --- a/docs/subsystems/bash.md +++ b/docs/subsystems/bash.md @@ -136,7 +136,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The fields are owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` diff --git a/docs/subsystems/bash.zh.md b/docs/subsystems/bash.zh.md index b38a4f3329..409a00f8e5 100644 --- a/docs/subsystems/bash.zh.md +++ b/docs/subsystems/bash.zh.md @@ -136,7 +136,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。这些字段归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 47783d5f77..1ae9462b90 100644 --- a/docs/subsystems/code-runtime.i18n.yaml +++ b/docs/subsystems/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/code-runtime.md -code-runtime.md: 6d3fa7fa72891897155cb9c611c7aa472629de4f +code-runtime.md: a40801313d1adbdd1d601d319c9e5a563db478b6 code-runtime.zh.md: 47516c7d21c24399498a05410ab9a46b61736fb4 diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index 6d3fa7fa72..a40801313d 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -36,7 +36,7 @@ interface CodeRunRequest { } ``` -The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): +The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (matching `BashExecutor.run`'s resolve-on-failure contract): ```ts type-equiv /** diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index d2fe7303fc..7dc8602b93 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/compaction.md -compaction.md: fdff28ac2ec83966a03050ba8d54fe0ee26c4fc6 -compaction.zh.md: e4227320d9829cac695dc4ba0c5779086f77194b +compaction.md: 8c2a987bc8423841fc23f81bde334c54bb5402c1 +compaction.zh.md: 42ebd7edcf9b64cb9ddcb9a81fef1fc0b2f834b3 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index fdff28ac2e..8c2a987bc8 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored. -These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. +These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative fields. ## `CompactionResult` @@ -85,7 +85,7 @@ type ManualCompactionErrorCode = Pressure compaction runs at serial `agent/pre-step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. -The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. +The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for the tool-call/result pairing checks before and after a seq. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) defines their cache behavior. ## Tool-result pruning outcomes diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index e4227320d9..42ebd7edcf 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -20,7 +20,7 @@ 这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。 -这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 +这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威字段请循源码链接查看。 ## `CompactionResult` @@ -85,7 +85,7 @@ type ManualCompactionErrorCode = 压力压缩在串行 `agent/pre-step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 -该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于这些边缘检查。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;其缓存语义由[包约定](../../packages/compact/compact/README.md#tool-pairing-boundaries)规定。 +该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于检查 seq 之前与之后的工具调用/结果配对。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;[包约定](../../packages/compact/compact/README.md#tool-pairing-boundaries)定义其缓存行为。 ## 工具结果剪枝产出 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..9915a99b63 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: 75f55fe5b1837576ba79564b9aee7e289f5f16f4 +core.zh.md: 7cd55b41c8f1358a89c6f35a4d3f733ccd8550ee diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..75f55fe5b1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -2,7 +2,7 @@ English | [中文](core.zh.md) -The **core** subsystem is [`packages/core`](../../packages/core/README.md) — the control spine every composition boots: the event-sourced session log, system-prompt assembly, the tool registry, the agent vocabulary, and the concrete loop that drives them. This page owns what the `agent`/`agent-loop` pair declares — how an agent is created and owned, and the `Agent` handle with its delivery, cancellation, and interception contracts — plus the two type patterns every subsystem follows; the group's dedicated pages and the rest of the folder are indexed in the [subsystems README](README.md). +The **core** subsystem is [`packages/core`](../../packages/core/README.md) — the packages every composition boots: the event-sourced session log, system-prompt assembly, the tool registry, the agent types, and the concrete loop that drives them. This page explains what the `agent`/`agent-loop` pair declares — how an agent is created and owned, and the `Agent` handle's delivery, cancellation, and interception contracts — plus the two type patterns every subsystem follows. The group's dedicated pages and the rest of the folder are indexed in the [subsystems README](README.md). ## The spine, package by package @@ -48,7 +48,7 @@ interface AgentHandle { `CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, seed boundary, origin classification, delegation depth), an optional `seed` replay prefix for forks, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation contract behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers program against `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and their rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -206,11 +206,11 @@ The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, che ## Initiating Agent -The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns its lifetime and boundary rules. +The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules. ## Interception decisions -Pre-step decisions use the same identified `UserMessage` shape as durable user-role input. The entered batch is authoritative and preserves every message's id and source. Hook bridges map their native decision fields onto this typed result. +Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -232,7 +232,7 @@ type PreStepDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/pre-step` is the single serial boundary before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. +`agent/pre-step` is the only serial listener chain before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain. `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): @@ -245,13 +245,13 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`. -The `SessionEvent` envelope's exact conditional shape, the twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` seam, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. +The `SessionEvent` envelope's exact conditional fields, the twelve event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `steering/message`, `todo/write`, `request/header`), the `deriveMessages()` projection rules, the `TurnTrigger`/`TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL/SQLite backends, the `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**. ## `ToolDefinition` -The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through. +The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional final-content and UI callbacks. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed arguments), but it is the contract the registry holds and the loop dispatches through. -Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall shapes, and the tool-presentation UI vocabulary are on **[tools.md](tools.md)**. +Its full fields, the `defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` typed schema DSL, the `ToolExecution`/`ToolExecutionResult` waterfall types, and the tool-presentation UI types are on **[tools.md](tools.md)**. ## Repo-wide type patterns @@ -259,7 +259,7 @@ Two patterns recur across every subsystem and are documented once, here. ### The `…Map → derived-union` pattern -Almost every extensible sum type in the harness follows one shape: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. +Almost every extensible sum type in the harness follows one pattern: an interface keyed by a discriminant tag (the `…Map`), from which the union is derived with `keyof`. Plugins add variants by **declaration merging** — no edit to the owning package. ```ts ignore-check // The pattern, schematically: @@ -293,7 +293,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ### Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs passed between packages are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..7cd55b41c8 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -2,7 +2,7 @@ [English](core.md) | 中文 -**核心**子系统即 [`packages/core`](../../packages/core/README.md)——每个组合都会启动的控制主干:事件溯源的会话日志、系统提示词组装、工具注册表、agent 词汇,以及驱动它们的具体循环。本页拥有 `agent`/`agent-loop` 这对包所声明的内容——agent 如何被创建与拥有,以及 `Agent` 句柄及其投递、取消与拦截约定——外加每个子系统都遵循的两个类型模式;该组的专属页面与目录其余部分见[子系统 README](README.md)。 +**核心**子系统即 [`packages/core`](../../packages/core/README.md),包含每个组合都会启动的包:事件溯源的会话日志、系统提示词组装、工具注册表、agent 类型,以及驱动它们的具体循环。本页说明 `agent`/`agent-loop` 这对包所声明的内容:agent 如何被创建与拥有,以及 `Agent` 句柄的投递、取消与拦截约定;本页还说明每个子系统都遵循的两个类型模式。该组的专属页面与目录其余部分见[子系统 README](README.md)。 ## 主干逐包速览 @@ -50,7 +50,7 @@ interface AgentHandle { `CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、seed 边界、来源分类、委派深度)、fork 用的可选 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应物:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose 都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建约定:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方面向 `ctx.agents` 编程,无需依赖具体循环包。确切的 `create`/`resume` 签名及其回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 <a id="the-agent-handle"></a> @@ -206,17 +206,17 @@ type AgentCancelCause = cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录谁请求了取消,应使用单独的持久事件,而不是让终态结果承担额外含义。 -[事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 +[事件分类](../architecture.md#event)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 <a id="initiating-agent"></a> ## 发起 Agent -`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;其生命周期与边界规则由 [initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)规定。 +`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)定义其生命周期和作用域规则。 ## 拦截决策 -pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 形状。进入步骤的批次具有权威性,并保留每条消息的 id 和 source。钩子桥接层把其原生决策字段映射到这一类型化结果上。 +pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessage` 类型。进入步骤的批次具有权威性,并保留每条消息的 `id` 和 `source`。钩子桥接层把其原生决策字段映射到这一类型化结果上。 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) @@ -238,7 +238,7 @@ type PreStepDecision = type RequestErrorAction = { kind: 'retry' } | undefined ``` -`agent/pre-step` 是请求推导前唯一的串行边界。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 +`agent/pre-step` 是请求推导前唯一的串行监听器链。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。 `agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart): @@ -251,13 +251,13 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' `Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。 -`SessionEvent` 信封的确切条件形状、十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` seam、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 +`SessionEvent` 信封的确切条件字段、十二种事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`steering/message`、`todo/write`、`request/header`)、`deriveMessages()` 投影规则、`TurnTrigger`/`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL/SQLite 后端、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.md)** 中。 ## `ToolDefinition` -唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会用类型化参数构建),但它是注册表持有、循环分发所经过的约定。 +唯一属于核心的流水线编写类型:每个已注册工具*是什么*——一个面向模型的 `ToolSchema` 加上一个 `execute` 函数,以及可选的最终内容回调与 UI 回调。工具作者很少手动构造它(`defineTool` DSL 会使用类型化参数构建),但它是注册表存储并由循环用于分发的约定。 -其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 形状,以及工具展示 UI 词汇在 **[tools.md](tools.md)** 中。 +其完整字段、`defineTool`/`ValueSchemaSpec`/`ParameterSchemaSpec` 类型化 schema DSL、`ToolExecution`/`ToolExecutionResult` waterfall 类型,以及工具展示 UI 类型都在 **[tools.md](tools.md)** 中。 ## 全仓通用类型模式 @@ -265,7 +265,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ### `…Map → derived-union` 模式 -harness 中几乎所有可扩展的和类型都遵循同一形状:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 +harness 中几乎所有可扩展的和类型都遵循同一模式:一个以判别标签为键的接口(`…Map`),联合类型由 `keyof` 派生。插件通过**声明合并**添加变体——无需修改拥有该类型的包。 ```ts ignore-check // The pattern, schematically: @@ -301,7 +301,7 @@ declare module '@deepseek-ai/dsh-llm' { ### 品牌化 ID -跨越包边界的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 +在包之间传递的 ID 都经过**品牌化**——结构上是字符串,但在类型层面不可互换(不能把 `SessionId` 传给需要 `CallId` 的位置)。每种类型通过各自的工厂构造;比较、日志记录和 JSON 行为与普通字符串相同。 `Branded<B>` 原语位于独立的纯类型包 [dsh-brand](../../packages/util/brand) 中(没有运行时代码,也不依赖 Harness 包),因此任何包都能品牌化其拥有的 id,而无需依赖无关的能力包。 diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 7e9e057c75..c0ca84eb9f 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/credentials.md -credentials.md: 0bc2224ac039addc795d3806e8c85004f2bb84a7 -credentials.zh.md: f236b0b2daef85784308f10dbcfc67db84c42234 +credentials.md: 1d168999d338ba43c92150b171f89b610850f694 +credentials.zh.md: af92f17b10c80d3f78c61e306c526a165e48381b diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 0bc2224ac0..1d168999d3 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -8,7 +8,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ## Identity -A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape. +A reference names one credential as a POSIX-style environment-variable name. The brand prevents callers from mixing credential references with other strings passed between packages or processes; construction validates the shell-identifier syntax. ```ts type-equiv /** Nominal reference to one credential: a POSIX-style environment-variable name. */ diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index f236b0b2da..af92f17b10 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -8,7 +8,7 @@ ## 标识 -引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。 +引用以 POSIX 风格环境变量名命名一条凭据。brand 防止调用方将凭据引用与在包或进程之间传递的其他字符串混用;构造时校验 shell 标识符语法。 ```ts type-equiv /** Nominal reference to one credential: a POSIX-style environment-variable name. */ diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index fac61bde63..34d90d18f2 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md -filesystem.md: e0edad514b0c4d9108b18cc0b472600b29024c51 -filesystem.zh.md: 81c2d87b6a96740c7a99449a27c16331f5a0645a +filesystem.md: 3c154af0fa4ee6d28f2379c5392dcc9b194a2c99 +filesystem.zh.md: 5b8a136af156c63de72e260d5859d8bac7b827c5 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index e0edad514b..3c154af0fa 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -4,7 +4,7 @@ English | [中文](filesystem.zh.md) The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) records observed presence or absence and adds freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. -The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. +`dsh-fs-policy` is optional. Without it, the `FileSystem` Service Definition, a provider, and the `dsh-tool-fs` Consumer form the complete, unconstrained filesystem seam: `write` unconditionally creates or overwrites, and `edit` unconditionally replaces literal text. The policy plugin changes these operations by deciding the `fs/*` waterfalls. Removing it does not break the tool because the tool calls `ctx.fs` and dispatches events; it does not call policy methods. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). @@ -113,7 +113,7 @@ interface FsDirEntry { ## Write and edit guards (provider contract) -Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. +Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`, including a target that appears after the provider's initial probe because publication itself must be no-replace; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit both use the same optional `expected` field. ```ts type-equiv /** @@ -196,15 +196,15 @@ type FsObservation = ## Execution context (policy plugin) -The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. +The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` has the required fields, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. @@ -220,7 +220,7 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. After the byte cap is reached, scanning continues without retaining more lines so `totalLines` remains exact. The result the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits a present `fs/observed` directly with the stat's version), so any windowed read can authorize a later write/edit when the file is unchanged. A metadata miss emits an absent observation before the tool returns `FS_NOT_FOUND`, allowing a later guarded write to recreate an externally deleted target without authorizing edit. `dsh-tool-fs`, the executor that owns the read, implements read windowing and constructs this result; the policy plugin does not. ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 81c2d87b6a..5b8a136af1 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -4,7 +4,7 @@ 可选的文件系统能力由四个部分组成:[dsh-fs](../../packages/fs/fs) 拥有 `ctx.fs` 以及带可选守卫的原子文本操作;[dsh-fs-local](../../packages/fs/fs-local) 实现本地磁盘后端;[dsh-fs-policy](../../packages/fs/fs-policy) 记录观测到的存在或缺失状态,并通过事件(而非服务)添加新鲜度规则;[dsh-tool-fs](../../packages/fs/tool-fs) 直接执行面向模型的 read/write/edit 调用并渲染窗口。它位于 agent loop(智能体循环)主干之外;替换后端不会改变策略或工具 schema。 -该模型是**加法式而非减法式**的:`ctx.fs` 本身就是一个完整、无约束的文本存储 seam(`write` 无条件创建或覆盖,`edit` 无条件替换字面文本)。`dsh-fs-policy` 是一个插件,通过裁决 `fs/*` waterfall(瀑布式事件)在上层*叠加*策略;移除它只会暴露裸提供方,而不会破坏工具,因为工具与策略之间没有方法级耦合。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 +`dsh-fs-policy` 是可选插件。没有该插件时,`FileSystem` 服务定义、一个提供方和 `dsh-tool-fs` 消费方组成完整且不受约束的文件系统 seam:`write` 无条件创建或覆盖,`edit` 无条件替换字面文本。策略插件通过裁决 `fs/*` waterfall(瀑布式事件)来改变这些操作。移除该插件不会破坏工具,因为工具调用 `ctx.fs` 并分发事件,而不调用策略方法。加载了 `dsh-tool-fs` 的部署通常也应加载 `dsh-fs-policy`,使默认行为为「先读后写/编辑」。 提供方源码:[`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) 与 [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts)。策略源码:[`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts)。读取渲染源码:[`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts)。 @@ -113,7 +113,7 @@ interface FsDirEntry { ## 写入与编辑守卫(提供方约定) -`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 共享同一个对称的 `expected?` 形状。 +`writeText` 和 `editText` 的版本守卫都是可选的:省略守卫时执行无条件的裸提供方变更,提供守卫时则执行相应的条件检查。`writeText` 的守卫是 `FsWriteIntent`:`createIfAbsent` 在目标缺失时创建,目标已存在时以 `FS_NOT_OBSERVED` 拒绝;即使目标在提供方初始探测后才出现,也必须拒绝,因为发布操作本身不得替换。`replaceIfVersion` 仅在目标存在且版本匹配时替换,否则报 `FS_STALE_VERSION`。省略 `expected` 则无条件创建或覆盖。联合类型本身只包含两种有守卫的意图;「无守卫」通过省略表达,因此 write 和 edit 都使用同一个可选的 `expected` 字段。 ```ts type-equiv /** @@ -196,15 +196,15 @@ type FsObservation = ## 执行上下文(策略插件) -策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 满足此形状,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 +策略插件只需要足够的执行上下文,通过收窄 `fs/*` 事件携带的不透明 `object` actor 来推导观测状态的所有者。`ToolExecution` 包含必需的字段,因此 `dsh-tool-fs` 将其执行对象作为 actor 直接传递,而无需让 `dsh-fs-policy` 导入工具、agent 或会话包。 ```ts type-equiv /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. @@ -220,7 +220,7 @@ interface FsPolicyExec { ## 读取结果(消费方 / 读取渲染) -文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具以 stat 的版本 emit 表示存在的 `fs/observed`),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带防护的写入可以重新创建外部删除的目标,但不会授权 edit。读取窗口化与此结果形状位于 `dsh-tool-fs`(拥有读取操作的执行器)中,而非策略插件中。 +文本读取受行窗口、字节上限和后端限制约束。达到字节上限后,扫描仍会继续,但不再保留更多行,因此 `totalLines` 仍为精确值。面向模型的 `read` 工具渲染的结果纯粹是展示性的;不存在 `full`/`partial` 视图区分——授权基于新鲜度(工具 emit 表示存在的 `fs/observed`,并直接携带 stat 的版本),因此任何窗口化读取在文件未变时都能授权后续的 write/edit。元数据未命中时,工具会在返回 `FS_NOT_FOUND` 前 emit 缺失观测,使后续带守卫的写入可以重新创建外部删除的目标,但不会授权 edit。拥有读取操作的执行器 `dsh-tool-fs` 实现读取窗口化并构造该结果;策略插件不执行这些操作。 ```ts type-equiv /** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index cdd932726e..f610740a21 100644 --- a/docs/subsystems/goal.i18n.yaml +++ b/docs/subsystems/goal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/goal.md -goal.md: 6f54a5261cb44c3fda389e37cb689a00061ab241 -goal.zh.md: ea5a0fe7923648aead5e6f54ab31162a7abdb954 +goal.md: 93837e15ee3244671cacaed26983e8e4ca38bd56 +goal.zh.md: 8ea138bc80a400120b065f616182acb0906c68dd diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index 6f54a5261c..93837e15ee 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -2,7 +2,7 @@ English | [中文](goal.zh.md) -Types shared by the event-sourced goal domain and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the literal shapes from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). +Types shared by the event-sourced goal service and its policy consumers. The [goal-domain Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md) owns the persistence and activation decisions; this page records the exact fields and variants from [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts). ## Identity and lifecycle @@ -142,7 +142,7 @@ interface GoalChanged { ## Service behavior -[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) owns the callable and model-visible contract. +[`GoalService`](../../packages/goal/goal/src/index.ts) resolves creation defaults, folds strict replay from durable `goal/change` events, enforces exact-live-agent identity and compare-and-set mutations, and emits contained `goal/changed` notifications. The package [README](../../packages/goal/goal/README.md) defines the callable API and model-visible contract. <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index ea5a0fe792..8ea138bc80 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -2,7 +2,7 @@ [English](goal.md) | 中文 -事件溯源目标领域及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的字面形态。 +事件溯源目标服务及其策略消费方共享的类型。[目标领域 Agent Note](../../.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md)负责记录持久化与激活决策;本页记录 [`packages/goal/goal/src/types.ts`](../../packages/goal/goal/src/types.ts) 中的确切字段和变体。 ## 标识与生命周期 @@ -142,7 +142,7 @@ interface GoalChanged { ## 服务行为 -[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 负责记录可调用约定和面向模型的约定。 +[`GoalService`](../../packages/goal/goal/src/index.ts) 解析创建默认值、从持久 `goal/change` 事件执行严格回放折叠、校验确切的活跃 agent 身份、以比较并设置方式执行变更,并发出 `goal/changed` 通知;监听器故障会被隔离。包 [README](../../packages/goal/goal/README.md) 定义可调用 API 和面向模型的约定。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/http-server.i18n.yaml b/docs/subsystems/http-server.i18n.yaml index 6f61102676..4c3975588b 100644 --- a/docs/subsystems/http-server.i18n.yaml +++ b/docs/subsystems/http-server.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/http-server.md -http-server.md: b9795fe98b432b6ef5f7d01a4d3e115c809fe642 -http-server.zh.md: 8d55c48ae2882bd471ca4e64e011c7d79c4ebb40 +http-server.md: 232755ba6b77ec940ebe2cb7f19fd962018300f1 +http-server.zh.md: 59778e6455d4f32e30c3e77c88cd76a2ecc1845f diff --git a/docs/subsystems/http-server.md b/docs/subsystems/http-server.md index b9795fe98b..232755ba6b 100644 --- a/docs/subsystems/http-server.md +++ b/docs/subsystems/http-server.md @@ -2,7 +2,7 @@ English | [中文](http-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the web-shape HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.httpServer`, a named-route registry, index.html transform taps, and a single claimable fallback seat. It is not part of the agent-loop spine and not a capability seam — it knows no harness concepts, and every feature surface (the `/api` bridge, plugin bundles, the HMR event stream) is a route some other plugin registers ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). Web (browser) shape only: Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.httpServer`, a named-route registry, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -42,7 +42,7 @@ interface Config { ## The service -`HttpServerService` (`ctx.httpServer`) listens immediately on activation; a listen failure (EADDRINUSE…) throws out of init — a FAILED fiber the boot's fail-loud sweep reports. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws, because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, the OS-assigned value when `config.port` is 0. +`HttpServerService` (`ctx.httpServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). @@ -58,7 +58,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. ```ts cordis-catalog /** @@ -104,5 +104,5 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/http-server.zh.md b/docs/subsystems/http-server.zh.md index 8d55c48ae2..59778e6455 100644 --- a/docs/subsystems/http-server.zh.md +++ b/docs/subsystems/http-server.zh.md @@ -2,7 +2,7 @@ [English](http-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主 web 形态的 HTTP 载体:单个提供 `ctx.httpServer` 的 `node:http` 插件,由具名路由注册表、index.html 转换挂点与单一可认领的回退席位组成。它不属于 agent loop(智能体循环)主干,也不是能力 seam:它不了解任何 harness 概念,每个功能表面(`/api` 桥接、插件 bundle、HMR(热模块替换)事件流)都是由其他插件注册的一条路由([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md))。仅限 web(浏览器)形态:Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,不经过本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.httpServer` 的 `node:http` 插件,包含具名路由注册表、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -42,7 +42,7 @@ interface Config { ## 服务 -`HttpServerService`(`ctx.httpServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会从 init 抛出,形成一个 FAILED fiber,由启动的大声失败 sweep 上报。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯的 html 到 html 转换,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,`config.port` 为 0 时读到的是操作系统分配的值。 +`HttpServerService`(`ctx.httpServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯的 html 到 html 转换,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退);[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包(package)从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.md) 中。 @@ -58,7 +58,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.httpServer` — `HttpServerService` -The web-shape HTTP carrier service. Activation listens immediately (route registration order carries no request-facing semantics: named routes are composed to be disjoint, and the fallback seat answers anything not yet claimed during the boot window — 404 until its owner registers). A listen failure throws out of init — a FAILED fiber the boot's fail-loud sweep reports. +The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber. ```ts cordis-catalog /** @@ -104,5 +104,5 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string ``` -Source: [`packages/host/webserver/src/index.ts:60`](../../packages/host/webserver/src/index.ts) +Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index dfb482ba7e..052ec4fedd 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 9f92052d411e3bd4256db63df54eba7f4e313b18 -llm-streaming.zh.md: ab5540c9e7adce70f0462fb478e8f674d1f6bba4 +llm-streaming.md: 8ae4e8b376b4c4221e6179eb719fcf162f3031e4 +llm-streaming.zh.md: 7d244ab882521a90217873fc3cdee12cd5232db8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 9f92052d41..8ae4e8b376 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -2,7 +2,7 @@ English | [中文](llm-streaming.zh.md) -The conversation and streaming vocabulary of [`packages/llm`](../../packages/llm/README.md): the `Message`/`ContentBlock` shapes every request and durable history share, the fully-assembled model request, the raw `StreamChunk` protocol, the adapter contract every adapter must obey, and the shared assembler. The [core spine](core.md) holds and logs these values on every turn; this page declares them. +The conversation and streaming types from [`packages/llm`](../../packages/llm/README.md): the `Message`/`ContentBlock` variants every request and durable history share, the fully assembled model request, the raw `StreamChunk` protocol, the adapter contract every adapter must implement, and the shared assembler. The [core packages](core.md) hold and log these values on every turn; this page declares them. Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -79,12 +79,12 @@ interface MessageSourceMap { } ``` -Producer identity and content shape are independent. `kind` answers *who produced this*; the optional `form` a producer mixes in answers *what shape of information it is*, so several producers may share one presentation and one producer may emit more than one shape over a session. The vocabulary is semantic and grows one value at a time; an absent or unrecognized value is the documented default, presented as opaque content: +Producer identity and presentation form are independent. `kind` answers *who produced this*; the optional `form` answers *what kind of information this is*, and consumers decide how to present it. Several producers may share one form, and one producer may emit more than one form over a session. The values are semantic and grow one at a time; an absent or unrecognized value uses the documented default and is presented as opaque content: ```ts type-equiv /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -126,10 +126,10 @@ interface ContextSnapshotSection { ```ts type-equiv /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ @@ -184,13 +184,13 @@ type StreamChunk = Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `providerRetryAfterMs` is a validated positive delay requested by the provider, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics. ```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -205,7 +205,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths, one fact shape.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. The final adapter boundary preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. +- **Two sanctioned error paths, one `LlmFailure` type.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. After the call selects its adapter, the stream preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt. - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered turn; direct `ctx.llm.stream()` callers remain single-attempt. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. @@ -215,7 +215,7 @@ Every adapter MUST obey these, and every consumer may rely on them: ## `ResolvedRetryPolicy` -Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the exact serving registration's captured value after that call enters its final adapter boundary, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) owns the optional input shapes. +Provider configuration resolves before route registration into an immutable discriminated union. Normal mode carries `mode: 'normal'`, finite `maxRetries`, `retryableCodes`, and required `initialDelayMs`, `maxDelayMs`, and `jitterRatio`; always mode carries `mode: 'always'` and the same required backoff fields without a finite maximum. `LlmService.providerRetryPolicy(provider)` returns the currently registered value and supplies normal defaults when the adapter omits one; `llmRetryPolicyOf(stream)` returns the value captured from the serving registration after the call selects that registration, so later route disposal or replacement cannot change an in-flight failure's recovery policy. The [generated config catalog](../config-catalog.md) lists the optional input fields. ## `AppIdentity` — app attribution @@ -533,7 +533,7 @@ interface ToolSchema { } ``` -The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). +The model-facing `ToolSchema` is the wire type; the registered `ToolDefinition` that produces it (schema + `execute`) is on [tools.md](tools.md). A provider a surface is still drafting has no route and no catalog, so interrogation is described separately: the request carries the draft the user is editing, and the reply is candidates a surface may adopt rather than a catalog it must serve. @@ -590,7 +590,7 @@ The loop builds each request from logged state. `EpochHeader` records call confi `agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. +On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history. The logged request snapshot ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). @@ -652,8 +652,8 @@ interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index ab5540c9e7..7d244ab882 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -2,7 +2,7 @@ [English](llm-streaming.md) | 中文 -[`packages/llm`](../../packages/llm/README.md) 的对话与流式输出词汇:每个请求与持久历史共享的 `Message`/`ContentBlock` 形状、完整组装的模型请求、原始 `StreamChunk` 协议、每个适配器必须遵守的适配器约定(adapter contract),以及共享的 assembler。[核心主干](core.md)在每个轮次持有并记录这些值;本页声明它们。 +[`packages/llm`](../../packages/llm/README.md) 提供对话与流式输出类型:每个请求和持久历史共用的 `Message`/`ContentBlock` 变体、完整组装的模型请求、原始 `StreamChunk` 协议、每个适配器必须实现的适配器约定(adapter contract),以及共享的 assembler。[核心包](core.md)在每个轮次持有并记录这些值;本页声明它们。 源码:[`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) @@ -79,12 +79,12 @@ interface MessageSourceMap { } ``` -生产方标识与内容形态相互独立。`kind` 回答「由谁产生」;生产方可选混入的 `form` 回答「这是何种形态的信息」,因此多个生产方可以共用一种呈现,一个生产方在一次会话中也可以发出多种形态。该词汇表是语义的,逐个取值增长;未声明或无法识别的取值是有文档的默认,按不透明内容呈现: +生产方标识与呈现形式相互独立。`kind` 回答「由谁产生」;可选的 `form` 回答「这是什么类型的信息」,消费方决定如何呈现。多个生产方可以共用一种 `form`,一个生产方在一次会话中也可以发出多种 `form`。这些取值描述语义,并逐个增加;未声明或无法识别的值使用文档规定的默认值,按不透明内容呈现: ```ts type-equiv /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -126,10 +126,10 @@ interface ContextSnapshotSection { ```ts type-equiv /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ @@ -188,13 +188,13 @@ type StreamChunk = 每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方无关的 payload。`providerRetryAfterMs` 是经校验、由提供方请求的正数延迟,而不是重试决策;`ProviderRequestId` 是用于诊断的不透明品牌字符串。 ```ts type-equiv -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -209,7 +209,7 @@ interface LlmFailure { - **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。 - **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。 -- **两条受支持的错误路径,一种事实形状。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。最终适配器边界保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 +- **两条受支持的错误路径,共用一个 `LlmFailure` 类型。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。调用选定适配器后,流会保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的轮次;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 @@ -219,7 +219,7 @@ interface LlmFailure { ## `ResolvedRetryPolicy` -提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用进入最终适配器边界后,`llmRetryPolicyOf(stream)` 返回为其提供服务的确切注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选输入形状由[生成的配置目录](../config-catalog.md)规定。 +提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 `mode: 'normal'`、有限的 `maxRetries`、`retryableCodes`,以及必填的 `initialDelayMs`、`maxDelayMs` 与 `jitterRatio`;always mode 携带 `mode: 'always'` 和相同的必填退避字段,但没有有限上限。`LlmService.providerRetryPolicy(provider)` 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用选定该注册后,`llmRetryPolicyOf(stream)` 返回为该调用服务的注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选配置输入字段由[生成的配置目录](../config-catalog.md)列出。 ## `AppIdentity`:应用归属 @@ -541,7 +541,7 @@ interface ToolSchema { } ``` -面向模型的 `ToolSchema` 是协议格式;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 +面向模型的 `ToolSchema` 是协议类型;产出它的已注册 `ToolDefinition`(schema + `execute`)在 [tools.md](tools.md) 中。 界面正在起草的提供方既没有路由也没有 catalog,因此询问被单独描述:请求携带用户正在编辑的草稿,回复是界面可以采纳的候选,而不是它必须服务的 catalog。 @@ -598,7 +598,7 @@ interface LlmDiscoveredModel { `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 +在协议中,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史。已记录的请求快照会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 @@ -660,8 +660,8 @@ interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ declare abstract class LlmAdapter { /** diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 9e0a373532..65925a1608 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 8f6872b77be8c7ae273e0fc1887dca30dbe1eb37 -persistence.zh.md: 25e72a69bd03cd5cc0715ae769055062466397dd +persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 +persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 8f6872b77b..0266d17393 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -211,7 +211,7 @@ interface SessionPersistenceSnapshot { Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row fields `(session_id, seq, type, time, data, source_event_seqs, surface_op)` map 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 25e72a69bd..ced8344016 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -211,7 +211,7 @@ interface SessionPersistenceSnapshot { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行结构 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行字段 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 2286bad3fd..ccf85341d1 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/plan.md -plan.md: 29a2473c440b654422c4c563820efa72847a820a -plan.zh.md: bcb327719102b90c1c7009d78cee0fa930cdc7a8 +plan.md: 749f1052119f25e34dcfa8e6939a97037dc3b6dc +plan.zh.md: 31a402651f5c15b9c0cc264a7a51a020b4e63d60 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 29a2473c44..749f105211 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -2,7 +2,7 @@ English | [中文](plan.zh.md) -Plan mode is logged per-agent collaboration state owned by [dsh-plan-mode](../../packages/plan/plan-mode) (`ctx.planMode`, `PlanModeService`): while active, a deployment-owned guidance section shapes each model request. It is **soft guidance**, deliberately independent of the [sandbox mode](sandbox.md) and [approval policy](approval.md) enforcement axes — those knobs never read or write plan state, and deployments needing a hard boundary combine them separately. The package is one optional capability, not part of the agent-loop spine; its surfaces are the `plan:policy` prompt section, the always-registered `exit_plan_mode` tool, and the `/plan` command. The [design note](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) owns the rationale; the [package README](../../packages/plan/plan-mode/README.md) owns the model-experience and limitation detail. +Plan mode is logged per-agent collaboration state owned by [dsh-plan-mode](../../packages/plan/plan-mode) (`ctx.planMode`, `PlanModeService`): while active, a deployment-owned guidance section is included in each model request. Plan mode is **soft guidance**. [Sandbox mode](sandbox.md) and [approval policy](approval.md) enforce restrictions independently; neither reads or writes plan state, so deployments configure them separately. The package is optional, and the agent loop does not depend on it. It contributes the `plan:policy` prompt section and registers the `exit_plan_mode` tool and `/plan` command. The [design note](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md) owns the rationale; the [package README](../../packages/plan/plan-mode/README.md) owns the model-experience and limitation detail. Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/src/index.ts) @@ -10,11 +10,11 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. `foldPlanMode(events, end?)` returns the last logged value in the prefix, or `false` when there is none — the state in force is always a pure fold of the session log, so resume, fork, and compaction recover it with no live mirror, and UIs observe committed flips through `session/event`. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). -## Pending intent and the step-boundary flush +## Pending selections and the pre-step append -Because every session event is turn-enclosed, a user selection is held as pending intent until the next step boundary — the next request derivation, in whichever turn it occurs (selection never forces continuation, so an intent recorded after a turn's final step lands in a later turn). `set(agent, active)` records the pending selection (a no-op when the target equals the logged-or-already-pending state), and `get(agent)` returns `{ active: boolean; pending?: boolean }` — the logged state shaping the current step, plus the optimistic selection awaiting a boundary. +Because every session event is turn-enclosed, a user selection remains pending until the next accepted in-turn pre-step appends it before request derivation, in whichever turn that occurs. A selection never forces continuation, so one made after a turn's final accepted pre-step is appended in a later turn. `set(agent, active)` records the pending selection (a no-op when the target equals the logged-or-already-pending state), and `get(agent)` returns `{ active: boolean; pending?: boolean }`: the logged state used to assemble the current step plus the selected state waiting to be appended. -The sole flush point is a prepended `agent/step` listener — the loop's in-turn interception point that runs before every request derivation, including turn 1 step 1 and request-recovery retries. Prompt admission itself never flushes: it happens pre-turn, where a `plan/mode` append would land outside any open turn, so a selection made at the prompt is landed by the first step boundary inside the turn it starts. The prepend means the flush runs before the downstream `agent/step` listener chain. A flush failure is contained — plan policy can never block a turn — and the failed append stays pending for a later boundary. A flushed user selection also narrates the switch as one plugin-sourced `user/message` notice, but only when the last logged request header described the other state, so the model is told exactly when its context changed and never redundantly. A pending selection made while idle is process-local and lost on exit before the next boundary ([README limitation](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work)). +The only append point while an agent is running is a prepended `agent/pre-step` listener. It observes every proposed request step, including turn 1 step 1 and request-recovery retries, calls downstream listeners first, and appends only after they accept the step. Prompt admission happens before a turn and cannot append `plan/mode`, so a selection made at the prompt is appended by the first accepted in-turn pre-step of the turn it starts. An append failure cannot block the turn, and the selection remains pending for a later accepted in-turn pre-step. An appended user selection also records one plugin-sourced `user/message` notice, but only when the last logged request header described the other state, so the model is told exactly when its context changed and never redundantly. A selection made after a turn's final accepted pre-step remains process-local and is lost if the process exits before another accepted in-turn pre-step ([README limitation](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work)). ## Configuration @@ -26,17 +26,17 @@ interface PlanModeConfig { } ``` -A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than silently shaping nothing. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. +A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than being ignored. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. ## The exit tool and the `/plan` command -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so crossing the boundary changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-interaction seam](user-interaction.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that flushes after the step — plan guidance holds for the rest of the assistant's tool batch, and the tool result itself narrates the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-interaction seam](user-interaction.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode. -When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive — which also cancels a not-yet-flushed pending entry before plan mode ever reaches a request. +When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request. ## The service -`ctx.planMode` owns the logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodeservice). +`ctx.planMode` owns the logged plan state, applies and narrates selected state at step start, and owns the `plan:policy` section, the `/plan` command, and the stable exit tool; `get`/`set` signatures are in the generated [service catalog](#ctxplanmode--planmodeservice). <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> @@ -50,11 +50,12 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeService` -`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. ```ts cordis-catalog /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -62,25 +63,25 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index bcb3277191..31a402651f 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -2,7 +2,7 @@ [English](plan.md) | 中文 -计划模式是 [dsh-plan-mode](../../packages/plan/plan-mode) 拥有的、记录到日志的逐 agent(智能体)协作状态(`ctx.planMode`,`PlanModeService`):激活期间,一段部署持有的指引段落会影响每个模型请求。它是**软性指引**,有意独立于[沙箱模式](sandbox.md)与[审批策略](approval.md)这两条强制执行轴:那些旋钮(knob)从不读写计划状态,需要硬边界的部署另行组合两者。该包(package)是一项可选能力,不属于 agent loop(智能体循环)主干;它的对外表面是 `plan:policy` 提示词段落、始终保持注册的 `exit_plan_mode` 工具和 `/plan` 命令。[设计说明](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)负责决策依据;[包 README](../../packages/plan/plan-mode/README.md) 负责模型体验与限制细节。 +计划模式是 [dsh-plan-mode](../../packages/plan/plan-mode) 拥有的、记录到日志的逐 agent(智能体)协作状态(`ctx.planMode`,`PlanModeService`):激活期间,每个模型请求都会包含一段部署持有的指引。计划模式是**软性指引**。[沙箱模式](sandbox.md)与[审批策略](approval.md)分别强制限制;两者都不读写计划状态,因此部署需要分别配置它们。该包(package)是可选项,agent loop(智能体循环)不依赖它。它贡献 `plan:policy` 提示词段落,并注册 `exit_plan_mode` 工具和 `/plan` 命令。[设计说明](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)负责决策依据;[包 README](../../packages/plan/plan-mode/README.md)负责模型体验与限制细节。 源码:[`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/src/index.ts) @@ -10,11 +10,11 @@ `plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。`foldPlanMode(events, end?)` 返回前缀中最后一条已记录值,没有时返回 `false`:生效状态始终是会话日志的纯折叠,因此恢复、fork 与压缩(compaction)无需实时镜像即可将其复原,UI 通过 `session/event` 观察已提交的切换。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 -## 待定意图与步骤边界冲刷 +## 待生效选择与 pre-step 追加 -由于每个会话事件都位于轮次之内,用户的选择会作为待定意图保留到下一个步骤边界——即下一次请求派生,落在哪个轮次就在哪个轮次生效(选择绝不强制续行,因此在某轮最后一步之后记录的意图会在之后的轮次落地)。`set(agent, active)` 记录待定选择(目标值与已记录或已在待定中的状态相同时不做任何事),`get(agent)` 返回 `{ active: boolean; pending?: boolean }`,即影响当前步骤的已记录状态,加上正在等待边界的乐观选择。 +由于每个会话事件都位于轮次之内,用户选择会保持待生效状态,直到下一个被接受的轮内 pre-step 在派生请求之前追加该选择,无论该 pre-step 位于哪个轮次。选择不会强制续行,因此在某轮最后一个被接受的 pre-step 之后作出的选择会在之后的轮次追加。`set(agent, active)` 记录待生效选择(目标值与已记录或已在等待的状态相同时不做任何事),`get(agent)` 返回 `{ active: boolean; pending?: boolean }`:用于组装当前步骤的已记录状态,以及等待追加的已选状态。 -唯一的冲刷点是一个前置(prepend)注册的 `agent/step` 监听器——agent loop 的轮内拦截点,在每次请求派生之前运行,包括第 1 轮第 1 步和请求恢复重试。提示词提交本身绝不冲刷:它发生在轮次开启之前,此时追加 `plan/mode` 会落在任何开启的轮次之外,因此在提示词处做出的选择由它开启的轮次内的第一个步骤边界落地。前置注册意味着冲刷先于下游的 `agent/step` 监听器链运行。冲刷失败会被收容(计划策略绝不能阻塞轮次),追加失败的选择保持待定,等待后续边界。已冲刷的用户选择还会以一条插件来源的 `user/message` 通知叙述这次切换,但仅当最后记录的请求头描述的是另一种状态时才叙述,因此模型恰好在上下文变化时被告知,且绝不重复。空闲时做出的待定选择只存在于进程内,进程在下一个边界之前退出即丢失([README 限制](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work))。 +agent 运行时,唯一的追加点是前置(prepend)注册的 `agent/pre-step` 监听器。它会观察每个候选请求步骤,包括第 1 轮第 1 步和请求恢复重试;它先调用下游监听器,只在下游接受该步骤后追加。提示词提交发生在轮次开启之前,无法追加 `plan/mode`,因此在提示词处作出的选择由它开启的轮次内第一个被接受的 pre-step 追加。追加失败不能阻塞轮次,且该选择会继续等待之后被接受的轮内 pre-step。追加用户选择时还会记录一条插件来源的 `user/message` 通知,但仅当最后记录的请求头描述的是另一种状态时才记录,因此模型恰好在上下文变化时收到通知,且绝不重复。在某轮最后一个被接受的 pre-step 之后作出的选择只存在于进程内;如果进程在另一个被接受的轮内 pre-step 之前退出,该选择会丢失([README 限制](../../packages/plan/plan-mode/README.md#known-limitations-and-deferred-work))。 ## 配置 @@ -26,17 +26,17 @@ interface PlanModeConfig { } ``` -`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是静默地不产生任何指引。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.md);未激活的计划模式不贡献任何文本。 +`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是被忽略。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.md);未激活的计划模式不贡献任何文本。 ## 退出工具与 `/plan` 命令 -[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此跨越边界只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-interaction.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待定退出,在该步骤之后冲刷:计划指引在 assistant 本批工具调用的剩余部分继续生效,而工具结果本身叙述这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 +[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-interaction.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待定退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。 -当 [`ctx.commands`](commands.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在计划模式尚未进入任何请求之前,取消尚未冲刷的待定条目。 +当 [`ctx.commands`](commands.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。 ## 服务 -`ctx.planMode` 拥有已记录的计划状态、边界处的应用与叙述、`plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodeservice)。 +`ctx.planMode` 拥有已记录的计划状态,在步骤开始时应用并叙述选中的状态,还拥有 `plan:policy` 段落、`/plan` 命令和稳定注册的退出工具;`get`/`set` 签名见生成的[服务目录](#ctxplanmode--planmodeservice)。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> @@ -50,11 +50,12 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeService` -`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. ```ts cordis-catalog /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -62,25 +63,25 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:183`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index e3e42cdc9e..bd07ed71fb 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 4cbe0babb22406f7a48f0c19e982bb4757b4f44d -session-projection.zh.md: 5eada67a6eed914021e284fc5eabf203125b4b83 +session-projection.md: 6fdcfc64a5265c36f4396d91ee689c5f1cd1da18 +session-projection.zh.md: 3ca65ba40be32ed11e319c05410c035683f46488 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 4cbe0babb2..6fdcfc64a5 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -45,7 +45,7 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -86,7 +86,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous — a carrier reads it in the same tick as its page slice, which is what makes `asOfSeq` one consistent cut — and every value passes its unit's schema before leaving (an accidentally-async `view` returns a Promise, which fails that boundary parse loudly). The change feed fires once per unit whose state *reference* changed, per committed event: the same-reference discipline in `apply` is the gate. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. Every value passes its unit's schema before return; an accidentally async `view` returns a Promise, which schema validation rejects. The change feed fires once per unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 5eada67a6e..3ca65ba40b 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -45,7 +45,7 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -86,7 +86,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 是完全同步的:载体在切出页面切片的同一 tick 内读取它,`asOfSeq` 之所以是一个一致切面正系于此;且每个值在离开前都要经过其单元的 schema 校验(误写成异步的 `view` 会返回 Promise,让这道边界解析当场大声失败)。变更流对每个已提交事件、每个状态*引用*发生变化的单元各触发一次:`apply` 的同引用纪律就是那道闸门。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。每个值在返回前都会通过其单元的 schema 校验;如果 `view` 被误写为异步函数,它会返回 Promise,schema 校验将拒绝该值。对于每个已提交事件,变更流会为每个状态*引用*已变化的单元触发一次;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -162,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 4ba7a3392b..e69b3ef46f 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-query.md -session-query.md: 3b6d99af58ffb7c3e7b58cc4a3e9143732382861 -session-query.zh.md: c23e9ad53ef6a3e4a8e50a892824068f33e4e15b +session-query.md: 7a414c89124dde9972396f77281f50bf1643d716 +session-query.zh.md: e8b98709c98bbd013730d4bf3c9807a418680d7d diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 3b6d99af58..7a414c8912 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -456,7 +456,7 @@ async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFi /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> @@ -465,7 +465,7 @@ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index c23e9ad53e..e8b98709c9 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -456,7 +456,7 @@ async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFi /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> @@ -465,7 +465,7 @@ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index a73c858289..a7f5e0fe0b 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md -session-reference.md: f539a59b8d26182aff9746b6d6a39a86ba15cb45 -session-reference.zh.md: 1035614141936af1f94bdabe35b3104be319a762 +session-reference.md: 29fd85c74c54b0542a241b02cd31fc6c93012ae8 +session-reference.zh.md: 2a551de5d7bd08ed70b56035b617b2d69c9d6244 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index f539a59b8d..29fd85c74c 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -2,7 +2,7 @@ English | [中文](session-reference.zh.md) -Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) owns canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. +Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 1035614141..2a551de5d7 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -2,7 +2,7 @@ [English](session-reference.md) | 中文 -结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 负责规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 +结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index e9d7a2f936..7ba2ae289d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 6fb0cec4fd222ceafbd5b4111fe56f22505058ad -session.zh.md: d33a71e92e2bd9fd9fb7e9194255b7c1f5f0af77 +session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df +session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 6fb0cec4fd..0b78e51ebf 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -345,7 +345,7 @@ interface SurfaceFoldResult { ## `Session` public API -The body-stripped declaration keeps the plain class's detached factory, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore). +The body-stripped declaration keeps the plain class's detached factory, state accessors, append method, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` section](#ctxsessions--sessionstore). ```ts public-api /** @@ -404,8 +404,8 @@ declare class Session { static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d33a71e92e..d1e91f684a 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -177,7 +177,7 @@ interface EpochHeader { ### 路由容量事件:`request/context` -请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是由 `headerEquals` 逐字段比较的重建约定:容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 +请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是 `headerEquals` 逐字段比较的重建约定。容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 ```ts type-equiv /** Registration-bound metadata for one resolved model route. */ @@ -347,7 +347,7 @@ interface SurfaceFoldResult { ## `Session` 公共 API -去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、追加边界和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。 +去除方法体的声明与源码中的普通类保持同步,覆盖其脱离态工厂、状态访问器、append 方法和历史投影。存储操作仍由生成的 [`ctx.sessions` 小节](#ctxsessions--sessionstore)记录。 ```ts public-api /** @@ -406,8 +406,8 @@ declare class Session { static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session; /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index a257e0e33e..3fa99a4ecf 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/settings.md -settings.md: 9256bf9436d2e77093fc8c6a3728b62fc4e8d67f -settings.zh.md: 720eb9c2718c1fa14a148cced806c224634dba69 +settings.md: fc10f72c0ed59a982817eb65ecc410e039a2cbb3 +settings.zh.md: 96bb2d8b0cd65e1ebd812e000fe3b96e8b315493 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 9256bf9436..fc10f72c0e 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -8,7 +8,7 @@ Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/sett ## Identity -A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape. +A namespace names one plugin-owned section of the user document. The brand prevents callers from mixing settings namespaces with other ids passed between packages or processes; construction validates lowercase kebab-case syntax. ```ts type-equiv /** Nominal id of one registered settings namespace. */ @@ -79,14 +79,14 @@ interface SettingsScope<T> { watch(callback: (next: T, prev: T) => void | Promise<void>): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise<void> /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise<void> diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 720eb9c271..96bb2d8b0c 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -8,7 +8,7 @@ ## 标识 -namespace 命名用户文档中一个归插件所有的分节。brand 使其不与其他跨边界 id 混用;构造时校验小写 kebab-case 形态。 +namespace 命名用户文档中一个归插件所有的分节。brand 防止调用方将设置 namespace 与在包或进程之间传递的其他 id 混用;构造时校验小写 kebab-case 语法。 ```ts type-equiv /** Nominal id of one registered settings namespace. */ @@ -79,14 +79,14 @@ interface SettingsScope<T> { watch(callback: (next: T, prev: T) => void | Promise<void>): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise<void> /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise<void> diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 9b3d8a19d6..1aaffa63cc 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/storage.md -storage.md: 3234fdf9587dc2853b4f9ec4205d230aae4b633a -storage.zh.md: fb13ec96099f2173478aecf1b91799237755414c +storage.md: fd5a8d7eaeb545ef307434b211556633bba8b503 +storage.zh.md: 4ce5ebb2ad59315d2a60d57de087f3050c323722 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 3234fdf958..fd5a8d7eae 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -29,10 +29,10 @@ interface StorageForms {} /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional data-shape facets; `kv` is the sole facet. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) asserts every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores document-per-row in one database, the route for high-churn domains. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only group today. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index fb13ec9609..4ce5ebb2ad 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -29,10 +29,10 @@ interface StorageForms {} /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并暴露可选的数据形状 facet;`kv` 是唯一的 facet。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例槽位——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件对每个后端断言其中每一条款。[json 后端](../../packages/storage/storage-json/README.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.md)在单个数据库中按一行一文档存储,是高频更新领域的路由选择。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;目前 `kv` 是唯一一组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例槽位——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..ddbc9ec3db 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 99c54e0696c9c3585c50285ecb69b14c90d83c11 -subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672 +subagent.md: 961a16f58cb936205290d23ce6b9d94cb94607d5 +subagent.zh.md: ac97d27d9824ca62701e11ae99264adbddafbbd8 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..961a16f58c 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -2,7 +2,7 @@ English | [中文](subagent.zh.md) -The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. +The subagent seam lets an agent delegate work to a child agent. Like [bash](bash.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service providers are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). @@ -113,7 +113,7 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## Continuable children and activations -A **continuable background subagent** is one durable child Session with at most one process-local **Activation** — a residency epoch for a reconstructed child Agent. An Activation is not a request, result, cancellation, or Task boundary: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. +A **continuable background subagent** is one durable child Session with at most one process-local **Activation**, the period when a reconstructed child Agent is resident. An Activation is not a request, result, cancellation, or Task: it may execute many FIFO turns and stays resident while descendants it created are still running. The continuation manager owns activation admission, direct-parent authorization, the live ownership graph, cold resume, and child-first disposal; the Agent loop owns all turn ordering and execution. No continuable path creates a Task or an intermediate result-bearing wrapper. ```text persisted Session @@ -299,8 +299,9 @@ interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..ac97d27d98 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -2,7 +2,7 @@ [English](subagent.md) | 中文 -subagent seam:一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.md) 中。但它在一个维度上与其他所有 seam 不同:**同一上下文中可共存多个提供方实现**,按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。注册表的形状参照 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 +subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](bash.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。 Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service provider 是六个兄弟包:`dsh-subagent-spawn`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 @@ -113,7 +113,7 @@ interface ResolvedSubagentStartRequest extends SubagentStartRequest { ## 可继续子 agent 与激活 -**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**——即被重建的子 Agent 的一段驻留纪元(residency epoch)。Activation 不是请求、结果、取消或 Task 边界:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 +**可继续后台 subagent** 是一份持久化子 agent 会话(Session),至多关联一个进程内的 **Activation(激活)**,即被重建的子 Agent 处于驻留状态的时段。Activation 不是请求、结果、取消或 Task:它可以执行多个 FIFO 轮次,并在其创建的后代仍在运行期间保持驻留。继续执行管理器负责 activation 准入、直接父级鉴权、实时所有权图、冷恢复(cold resume)与子级优先释放;agent loop 负责一切轮次排序与执行。任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层。 ```text persisted Session @@ -299,8 +299,9 @@ interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 91ff1b49d6..c24ae31019 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: 5397858ea9991efad06e045118b96a90386f2285 -system-prompt.zh.md: defd8fae73834ba543ae1f45d15ca4253a5abe40 +system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 +system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 5397858ea9..bdc0e994fb 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -2,7 +2,7 @@ English | [中文](system-prompt.zh.md) -The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page pins the literal cross-package shapes that plugins implement or pass. +The [system-prompt package](../../packages/core/system-prompt) owns the data exchanged between prompt contributors and one assembly call. The package [README](../../packages/core/system-prompt/README.md) documents registration, ordering, scoping, and rendering behavior; this page records the exact cross-package types that plugins implement or pass. Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts). diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index defd8fae73..db6932b18f 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -2,7 +2,7 @@ [English](system-prompt.md) | 中文 -[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录了注册、排序、作用域与渲染行为;本页固定各插件实现或传递的跨包字面形状。 +[system-prompt 包](../../packages/core/system-prompt)负责管理提示词贡献者与一次组装调用之间交换的数据。该包的 [README](../../packages/core/system-prompt/README.md) 记录注册、排序、作用域与渲染行为;本页记录各插件实现或传递的确切跨包类型。 源码:[`packages/core/system-prompt/src/index.ts`](../../packages/core/system-prompt/src/index.ts)。 diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index c229d0a5c0..5956c1fc4f 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tasks.md -tasks.md: d3e92891a6736a85519b97775ebe7aa6f12a5ae2 -tasks.zh.md: fe54d7a6482743a8f2ef31b143edb25afe0a478b +tasks.md: de331045d6cbec64c1305b0a20f0c821e9578469 +tasks.zh.md: f33da2a7d0e09d094110c89f88b6f7508400da9b diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index d3e92891a6..de331045d6 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -2,7 +2,7 @@ English | [中文](tasks.zh.md) -Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). +Types shared by long-running producers, `ctx.tasks`, and task controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts). ## Ids and status @@ -57,7 +57,7 @@ interface TaskStart { } ``` -`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. +`TaskHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -151,7 +151,7 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index fe54d7a648..f33da2a7d0 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -2,7 +2,7 @@ [English](tasks.md) | 中文 -长时间运行的生产方、`ctx.tasks` 与任务控制接口共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的字面形状。 +长时间运行的生产方、`ctx.tasks` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)负责设计;本页记录 [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts) 中的确切字段和变体。 ## ID 与状态 @@ -57,7 +57,7 @@ interface TaskStart { } ``` -`TaskHooks.done` 是完全停稳边界。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +`TaskHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部提供方。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index c994c46baa..f5cda71a1d 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/telemetry.md -telemetry.md: 93869f2344eeedfee344735191af842fef188886 -telemetry.zh.md: 50642f1ed917ea52ee152e333f70753c12fba583 +telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb +telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 93869f2344..5ea5c67210 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). +Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -60,9 +60,8 @@ Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-sta ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -77,8 +76,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -105,7 +104,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. ## The redact waterfall: `telemetry/record` @@ -123,7 +122,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -142,7 +141,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 50642f1ed9..bd8fc8acc4 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 +对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -60,9 +60,8 @@ interface TelemetryRecord { ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -77,8 +76,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -105,7 +104,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 ## 脱敏 waterfall:`telemetry/record` @@ -123,7 +122,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -142,7 +141,7 @@ flush?(): void abstract shutdown(): Promise<void> ``` -Source: [`packages/session/session-telemetry/src/index.ts:140`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) <a id="telemetry-events"></a> diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index ed9d60978f..3f7e33a795 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 692bafa02e37e1c1918fda31c766ad32f7c7cdba -tools.zh.md: 81aabddd20e2a0d09d904f4f8521d65c2622351f +tools.md: f8d86704a2237219530c8c23b46a68383458e1cf +tools.zh.md: 87269e5532b0cdfb0a38501d7b98c9986665df1d diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 692bafa02e..f8d86704a2 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -2,7 +2,7 @@ English | [中文](tools.zh.md) -The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine; the model-facing [`ToolSchema`](llm-streaming.md#the-model-request-and-result) wire shape is declared with the model request. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the guarded execution shapes, and the UI-presentation vocabulary. +The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the pipeline-authoring type shared by the core packages; the model-facing [`ToolSchema`](llm-streaming.md#the-model-request-and-result) wire type is declared with the model request. This page documents every `ToolDefinition` field, the typed schema DSL that builds it, the guarded execution types, and the UI-presentation types. Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -148,7 +148,7 @@ type InferArgs<S> = InferProperties<S, []> `defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue<OutputSchema>`. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to `JsonValue`; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement. -Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire. +Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` constructs the model-facing projection when building a request, so execution and presentation share one resolved definition without leaking callbacks onto the wire. ## `ToolRestriction` — one scope's live global filter @@ -248,7 +248,7 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may reshape the durable event's copy of the content (the program's value and the model contract are untouched): +Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): ```ts type-equiv /** @@ -306,7 +306,7 @@ interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> { `ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity. -A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. +A `ToolGuard` is scope-aware final pre-dispatch policy. Its return type deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. ```ts type-equiv /** @@ -416,7 +416,7 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo ```ts type-equiv /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ interface JsonSchemaNode { @@ -565,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> @@ -590,23 +590,24 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) <a id="toolscode-dispatch-log--waterfall"></a> #### `tools/code-dispatch-log` — waterfall -Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -616,7 +617,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) <a id="toolsexecute--waterfall"></a> @@ -709,5 +710,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 81aabddd20..87269e5532 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -2,7 +2,7 @@ [English](tools.md) | 中文 -[dsh-tools](../../packages/core/tools) 的工具流水线。[core.md](core.md) 介绍了 `ToolDefinition`(唯一被提升到主干的流水线编写类型);面向模型的 [`ToolSchema`](llm-streaming.md#the-model-request-and-result) 协议格式(wire format)形状与模型请求一起声明。本页拥有完整的 `ToolDefinition`、用于构建它的类型化 schema DSL、受保护的执行形状,以及 UI 展示词汇。 +[dsh-tools](../../packages/core/tools) 的工具处理流程。[core.md](core.md) 介绍了核心包共用的流程编写类型 `ToolDefinition`;面向模型的 [`ToolSchema`](llm-streaming.md#the-model-request-and-result) 协议类型与模型请求一起声明。本页记录 `ToolDefinition` 的每个字段、用于构建它的类型化 schema DSL、带守卫的执行类型和 UI 展示类型。 源码:[`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) @@ -148,7 +148,7 @@ type InferArgs<S> = InferProperties<S, []> `defineTool({ name, description, parameters, output, execute, … })` 将参数推导与 `parameterSchemaSpecToJsonSchema()` 和 `validateArgs()` 绑定,并将 `execute`/`render`/`presentationMeta` 与 `InferValue<OutputSchema>` 绑定。Schema 记录只包含自有且可枚举的字符串键,schema 数组是稠密的内建数组,因此推导、编译与校验观察到的是同一份声明。精确推导保持到 16 层容器,之后放宽为 `JsonValue`;运行时校验仍会继续遍历完整 schema。`valueSchemaSpecToJsonSchema()` 通过同一套已强制执行的原始子集编译输出声明。参数不匹配时抛出 `ToolArgsError`(`INVALID_ARGS`);函数体或后置策略产生的值无效时抛出 `ToolOutputError`(`INVALID_TOOL_OUTPUT`)。两者都经由常规工具错误路径处理。原始 JSON Schema 默认保持开放;不支持的关键字会被拒绝,而不会在未强制执行的情况下获准进入。 -注册是一个受信任的同进程约定。注册表以 readonly 输入借用类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在模型边界处物化显式的面向模型投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 +注册是一项受信任的同进程约定。注册表以 readonly 输入借用已类型化定义,要求它声明 `output`,校验其原始 schema,并检查 `timeoutMs` 必须为正有限值等语义要求;`schemas()` 在构建请求时生成面向模型的投影,使执行和展示共享同一份已解析定义,而不会将回调泄漏到协议上。 ## `ToolRestriction` — 单个作用域的实时全局过滤器 @@ -248,7 +248,7 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以改写持久事件所存的内容副本(程序取得的值与模型约定均不受影响): +Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): ```ts type-equiv /** @@ -306,7 +306,7 @@ interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> { `ToolExecutionToken` 是不透明的运行时 `Symbol`,仅用于身份比较。策略执行前,`execute()` 会物化并冻结参数、拒绝非 JSON 输入并分配 token。身份字段、调用方必需的 signal 和可选的 parent token 均保持 readonly。`ToolDispatchExecution` 包装层可以替换 signal 但不能移除;注册表会在调用工具函数体前重新融合调用方的 signal。最终观察者接收冻结的执行身份。 -`ToolGuard` 是感知作用域的最终预分派策略。其形状有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 +`ToolGuard` 是感知作用域的最终预分派策略。其返回类型有意不包含 allow 结果:`undefined` 保留 waterfall 的决策,而返回的 reason 只能缩减权限,因此后续监听器无法撤销它。 ```ts type-equiv /** @@ -416,7 +416,7 @@ type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'bo ```ts type-equiv /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ interface JsonSchemaNode { @@ -565,7 +565,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:759`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:760`](../../packages/core/tools/src/index.ts) <a id="tools-events"></a> @@ -590,23 +590,24 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:193`](../../packages/core/tools/src/index.ts) <a id="toolscode-dispatch-log--waterfall"></a> #### `tools/code-dispatch-log` — waterfall -Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. ```ts cordis-catalog /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -616,7 +617,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:175`](../../packages/core/tools/src/index.ts) <a id="toolsexecute--waterfall"></a> @@ -709,5 +710,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:183`](../../packages/core/tools/src/index.ts) <!-- END GENERATED cordis-surface --> diff --git a/docs/subsystems/user-interaction.i18n.yaml b/docs/subsystems/user-interaction.i18n.yaml index 296bc8b766..0ea5d31f17 100644 --- a/docs/subsystems/user-interaction.i18n.yaml +++ b/docs/subsystems/user-interaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/user-interaction.md -user-interaction.md: a19155ae06af0133ae468c004b2e3b66f1de3fb8 -user-interaction.zh.md: c7aa9cafc9865191cda4089ad2beb214a33b00ef +user-interaction.md: 1ba4372d2c8ce2d8eeab46ce3aa4acdb11092ad3 +user-interaction.zh.md: 3e7e92d90216516ce1e38c69af88ed71701f1843 diff --git a/docs/subsystems/user-interaction.md b/docs/subsystems/user-interaction.md index a19155ae06..1ba4372d2c 100644 --- a/docs/subsystems/user-interaction.md +++ b/docs/subsystems/user-interaction.md @@ -8,7 +8,7 @@ Source: [`packages/interaction/user-interaction/src/index.ts`](../../packages/in ## Question options -`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. +`AskUserQuestionOption` contains one selectable choice. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. ```ts type-equiv /** One selectable answer offered to the user. */ @@ -22,15 +22,15 @@ interface AskUserQuestionOption { ## Presentation intent -`AskUserQuestionIntent` is the optional declaration that a question IS a decision of a known shape. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent shapes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads one answer shape either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. +`AskUserQuestionIntent` optionally declares a known decision kind. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent changes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads the same answer fields either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. ```ts type-equiv /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/docs/subsystems/user-interaction.zh.md b/docs/subsystems/user-interaction.zh.md index c7aa9cafc9..3e7e92d902 100644 --- a/docs/subsystems/user-interaction.zh.md +++ b/docs/subsystems/user-interaction.zh.md @@ -8,7 +8,7 @@ ## 问题选项 -`AskUserQuestionOption` 是可选择项的形状。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 +`AskUserQuestionOption` 包含一个可供选择的选项。`label` 是面向用户的选项文字,同时也是面向模型的选中值;`description` 是可选的 UI 帮助文本。 ```ts type-equiv /** One selectable answer offered to the user. */ @@ -22,15 +22,15 @@ interface AskUserQuestionOption { ## 呈现意图 -`AskUserQuestionIntent` 是一项可选声明:某个问题本身就是一次已知形状的决定。它按 `kind` 打标签,因此意图可以扩充;不认识某个标签的 UI 渲染通用选项列表。意图只塑造呈现 —— 遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 +`AskUserQuestionIntent` 可选地声明一种已知的决定类型。它按 `kind` 打标签,因此可以增加新的意图;不认识某个标签的 UI 渲染通用选项列表。意图只改变呈现方式——遵循它的 UI 回答的仍是通用 UI 会发送的那些 option label,因此调用方两种情况下读到的回答字段相同。`approve` 指名肯定选项,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上。 ```ts type-equiv /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index 8a11d774e6..bf0eef9ce4 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web.md -web.md: 2a7cc499b42dd8b45ce6783a35a2fadd8df1183d -web.zh.md: 6857f2a06a7d90702b6626409b4d8db914b3c465 +web.md: a411fc804133b012b77b7232e653decdb0f09c3d +web.zh.md: 595c315cf36dbbdf5b9a26870d5aeb1bbc0bb405 diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 2a7cc499b4..a411fc8041 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -125,7 +125,7 @@ Selection never depends on registration, config, or HMR order: a capability has ## Errors -`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by `WebService` selection and the shared contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. +`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by the shared `WebService` contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. ## The service @@ -178,7 +178,7 @@ registerFetchProvider(provider: WebFetchProvider): () => void * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 6857f2a06a..595c315cf3 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -125,7 +125,7 @@ type WebFetchBody = ## 错误 -`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。由 seam 统一定义的错误代码来自 `WebService` 的选择逻辑和共享约定:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 +`WebError extends HarnessError`([core.md](core.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。共享的 `WebService` 约定会抛出与 seam 无关的错误代码:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmService` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-local` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。 ## 服务 @@ -178,7 +178,7 @@ registerFetchProvider(provider: WebFetchProvider): () => void * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index f1ddf20d11..b18eeced08 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workflow.md -workflow.md: 4c552b31435963d4133a31f2e98f8a913305c1f8 -workflow.zh.md: 53ef84134b00e323bd5331ff30b18012124b5241 +workflow.md: 22dcaad608cc2ca7f407b8837fc3856abcc43555 +workflow.zh.md: 7ccd47f414ad574f2daa8e74f6cfb65abfbe06c2 diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 4c552b3143..22dcaad608 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -2,7 +2,7 @@ English | [中文](workflow.zh.md) -The workflow seam — an agent running a model-written orchestration SCRIPT that fans out subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). Unlike the subagent registry it takes the bash shape: ONE engine implementation per context provides `ctx.workflows`; there is no named-provider registry (a second engine is a plugin swap, not a co-resident). +The workflow seam lets an agent run a model-written orchestration SCRIPT that starts subagents. Like [subagent](subagent.md) it is **one optional capability**, not part of the agent loop, so its types and operations live here rather than in [core.md](core.md). Like bash, it permits ONE engine implementation per context to provide `ctx.workflows`; there is no named-provider registry (a second engine replaces the first through plugin configuration rather than running beside it). Service Definition: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The Service provider is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing Consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). @@ -10,13 +10,13 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work ## The start request -What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). +What a caller asks for when starting a run. The ordinary workflow tool builds this from the model's `{ script, meta, args }` call plus the calling agent; specialized consumers may also select one engine-wide `subagentProvider` and lower `maxTotalAgents` for the run, but the script cannot observe or replace either policy. `meta` and `args` are plain JSON DATA (the engine validates `meta` against its schema and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script starts is attributed to it, and cwd, lineage, and depth pass through the [subagent seam](subagent.md). ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -24,7 +24,7 @@ What a caller asks for when starting a run. The ordinary workflow tool builds th interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 53ef84134b..7ccd47f414 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -2,7 +2,7 @@ [English](workflow.md) | 中文 -工作流 seam 允许 agent(智能体)运行由模型编写的编排脚本,并由该脚本扇出 subagent。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此处而非 [core.md](core.md)。与 subagent 注册表不同,它采用 bash 形态:每个上下文只有一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎是插件替换,而非共存)。 +工作流 seam 允许 agent(智能体)运行由模型编写、会启动 subagent 的编排脚本。与 [subagent](subagent.md) 一样,它是**一项可选能力**,不属于 agent loop,因此其类型和操作记录在此处,而非 [core.md](core.md)。与 bash 一样,每个上下文只允许一个引擎实现提供 `ctx.workflows`;没有命名提供方注册表(第二个引擎通过插件配置替换第一个,而不与它同时运行)。 Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。Service provider 是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的 Consumer 是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 @@ -10,13 +10,13 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor ## 启动请求 -本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会校验 `meta` 的形状,并在任何工作开始前大声拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本生成的每个子 agent 都归属于它(cwd、谱系与深度通过 [subagent seam](subagent.md) 流转)。 +本节定义调用方启动一次运行时提交的请求。普通工作流工具会根据模型的 `{ script, meta, args }` 调用和发起调用的 agent 构建该请求;专用消费方还可以为本次运行选择引擎级 `subagentProvider`,并将 `maxTotalAgents` 调低,但脚本无法观察或替换这两项策略。`meta` 与 `args` 是普通 JSON 数据;引擎会用 schema 校验 `meta`,并在任何工作开始前拒绝无效数据。引擎绝不会通过对脚本文本求值来获取它们。`parent` 是必填字段——脚本启动的每个子 agent 都归属于它,cwd、谱系与深度通过 [subagent seam](subagent.md) 传递。 ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -24,7 +24,7 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 8809d62cd3..2b608ec739 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 62ac375844b7f05970e944278fd44ac60f68b4b1 -testing.zh.md: 6353b2799b5760d8c45534d0264bf41be063d659 +testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 +testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 diff --git a/docs/testing.md b/docs/testing.md index 62ac375844..f5e8a478ec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers -- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). +- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). @@ -30,8 +30,8 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## Test the real entry path -- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. -- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. +- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external services or nondeterministic inputs, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. +- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green when a default export replaces the required named exports — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/examples/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. ## Test resolution: source plane only @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 6353b2799b..bd1fa7d232 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -6,7 +6,7 @@ ## 层级 -- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性约定回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 +- **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 @@ -30,8 +30,8 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 测试真实入口路径 -- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部/不确定边界,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 -- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在导出形状损坏时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 +- 产品可见的插件必须有一个非单元的真实组合测试。手动构建的 `ctx.plugin(...)` 套件不够:通过 Loader 和 app/process 启动仅用于测试的 `cordis.yml`,只 mock 外部服务或非确定性输入,断言模型可见的请求/日志、持久状态或用户可见输出。不要把 opt-in 选项混入交付默认值。 +- 一个守卫只有在回归真的能让它失败时才有效。对于没有 `inject` 的插件(bundle/组合插件),Loader 冒烟测试在默认导出替换必需的具名导出时仍然绿着——需要添加显式的 `expect('default' in mod).toBe(false)` 加 `unwrapExports` 往返断言,并证明它有效:引入回归、观察变红、回退。 - 「真实入口路径」指已发布的产物:包的 `bin` 所运行的是构建后的 `lib/bin.js`,并由普通 `node` 执行,从而暴露 tsx 会掩盖的失败(等待稳定时的竞态、模块解析、被吞掉的加载失败)。同样的规则适用于非 index 运行时入口(worker-thread 的同级文件 `lib/worker.cjs`),也适用于多个 bundle 共享的单例模块(`packages/scaffold/server/tests/built-scope-carrier.e2e.ts`)。保持构建产物冒烟测试绿色(`packages/examples/*/tests/built-bin.e2e.ts`、`packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`),并断言真正缺失的配置以非零状态退出。 ## 测试解析:仅限源码 @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 037e063125..b629e4029e 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-execution-pipeline.md -tool-execution-pipeline.md: 16a4461b6c995f666882635dddb25e1a6cf79c73 -tool-execution-pipeline.zh.md: 3b5226c5ea8038c0fc5154cd37a77d92f6e3d0e1 +tool-execution-pipeline.md: 6c925a404d7a161838e72ce4b03b7f2cad29d313 +tool-execution-pipeline.zh.md: 15627023d3be6ac2b3aae70c2ef01ef9f1077d3e diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 16a4461b6c..6c925a404d 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -3,7 +3,7 @@ # Tool Execution Pipeline -This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them. +This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward. ```mermaid flowchart TD diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 3b5226c5ea..15627023d3 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -5,7 +5,7 @@ [English](tool-execution-pipeline.md) | 中文 -此图展示了策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染如何在不改变循环的前提下各就其位。可转换的扩展点是 `tools/pre-execute`、`tools/execute` 和 `tools/post-execute` waterfall(瀑布式事件);围绕这些扩展点的边界则由所有者强制执行,包括单调守卫、由定义自身控制的 `finalizeContent`,以及 `tools/result`。 +此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `finalizeContent` 和 `tools/result` 在此之后运行。 ```mermaid flowchart TD diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 2bb1d0ce7d..0d89e9622e 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: efedb07c8d757ef1f90d99fe1bf503a35c0f1a37 -index.zh.md: 2293a6086dc80fa77c88ef734ae17576ea513a10 +index.md: 7fe66bb19ddb978a4b5a96768b62151b97bca0ec +index.zh.md: 59f4e5b58b6cf1fbc15de8fafb5f4b0db2e220d6 diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index efedb07c8d..7fe66bb19d 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -26,7 +26,7 @@ export function apply(ctx: Context) { } ``` -That is the complete shape. +That is the complete configuration. ## Create the plugin file diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 2293a6086d..59f4e5b58b 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -26,7 +26,7 @@ export function apply(ctx: Context) { } ``` -这就是完整结构。 +这就是完整配置。 ## 创建插件文件 diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index b387f7a0b6..d849ac4ae0 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 1d1179a78c4d3a7e9e7055e3f5ee41381e28147e -publish.zh.md: d683762b78920dc754b6871ed7bd0caf3ee9afd4 +publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 +publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 1d1179a78c..7657654b14 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -33,7 +33,7 @@ hello-plugin/ } ``` -The patch file has the same shape as the `--patch` overlays you have been writing — a YAML array of patch entries — except plugin rows reference the package by name instead of a relative source path, so Node resolution finds the installed code: +The patch file is a YAML array of patch entries, like the `--patch` overlays you have been writing, except plugin rows reference the package by name instead of a relative source path so Node resolution finds the installed code: ```yaml - insert: @@ -41,7 +41,7 @@ The patch file has the same shape as the `--patch` overlays you have been writin name: dsh-hello-plugin ``` -A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. That is the correct shape for a library that plugin packages import rather than a plugin users enable. +A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable. ### The profile manifest diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index d683762b78..7af2ae3a06 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -33,7 +33,7 @@ hello-plugin/ } ``` -patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 patch 条目的 YAML 数组——只是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: +patch 文件与一直在写的 `--patch` overlay 一样,是一个 patch 条目的 YAML 数组;区别是插件行按包名而不是相对源码路径引用这个包,这样 Node 的模块解析才能找到已安装的代码: ```yaml - insert: @@ -41,7 +41,7 @@ patch 文件的形状与你一直在写的 `--patch` overlay 相同——一个 name: dsh-hello-plugin ``` -没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。这正是「供插件包 import 的库」应有的形状,区别于「供用户启用的插件」。 +没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。 ### profile manifest diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index 24ae066e89..fc15dfeb2f 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/index.md -index.md: 6ae9fd152ca2e3b82bdb1ea9b3aa3d348ebfce66 -index.zh.md: 0056a81402761310fe5f0463b83762d98ed9745f +index.md: 1eb33e17ab6c5d0a2b37ff97d5948dfbcba497ca +index.zh.md: 31afa80407f81f571615b5ed68a9370775f5188f diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 6ae9fd152c..1eb33e17ab 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -12,8 +12,8 @@ When a capability is general enough to need replaceable providers, such as Bash The Bash execution capability consists of: -- **Service Definition** (`dsh-bash`) — defines the Cordis service and Bash request/result vocabulary -- **Service provider** (`dsh-bash-local`) — supplies local command execution +- **Service Definition** (`dsh-bash`) — defines the Cordis service and Bash request and result types +- **Service provider** (`dsh-bash-local`) — executes commands on the local machine - **Consumer** (`dsh-tool-bash`) — exposes the capability as a model-callable tool ``` @@ -43,7 +43,7 @@ The Service Definition and tool remain unchanged while the provider changes. ### Evolve independently -- The Service Definition changes rarely after its contract stabilizes. +- The Service Definition changes rarely after callers depend on its contract. - Service providers can improve performance and security independently. - Consumers can change how they present the capability to the model. diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 0056a81402..31afa80407 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -12,8 +12,8 @@ 以 Bash 执行能力为例: -- **Service Definition** (`dsh-bash`):定义 Cordis 服务以及 Bash 请求/结果词汇 -- **Service provider** (`dsh-bash-local`):提供本地命令执行 +- **Service Definition** (`dsh-bash`):定义 Cordis 服务以及 Bash 请求和结果类型 +- **Service provider** (`dsh-bash-local`):在本地计算机上执行命令 - **Consumer** (`dsh-tool-bash`):将该能力公开为模型可调用的工具 ``` @@ -43,8 +43,8 @@ ### 独立演进 -- Service Definition 的约定稳定后很少改动 -- Service provider 可以独立优化性能和安全性 +- 调用方开始依赖 Service Definition 的约定后,Service Definition 很少改动。 +- Service provider 可以独立优化性能和安全性。 - Consumer 可以调整能力向模型呈现的方式。 ### 依赖解耦 diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 55277809ef..fc6744dea1 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/web-styling.md web-styling.md: 5296cc7f83f712532262eadda6da098ae9f63ec7 -web-styling.zh.md: bec623911269cbd7020c26d5574ee88ca074b5d8 +web-styling.zh.md: ed0906dae28a5a31f2e8e094b634ce6d9b518f31 diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index bec6239112..ed0906dae2 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -8,7 +8,7 @@ [`ui-theme`](../packages/client/ui-theme/README.md) 负责 `--dsw-*` 静态色阶、语义别名、排版、动效、渐变、阴影、滚动条样式以及明暗主题偏好。[`ui-layout`](../packages/client/ui-layout/README.md) 将解析后的主题快照应用到文档。功能包使用语义别名,不得另行定义全局主题。 -全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于组件自身的布局或呈现约定时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 +全局样式表归 `ui-theme/src/styles/` 所有。组件样式以 CSS Modules 形式放在组件旁。当某个值属于该组件的布局或呈现约定时,组件可以定义局部自定义属性;共享颜色、排版、层级和动效属于主题包。 ## 组件规则 diff --git a/examples/AGENTS.md b/examples/AGENTS.md index a1f87ac8fb..c1dfd47b4d 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -8,7 +8,7 @@ Extract reusable logic into `packages/`, where per-file coverage and README gate Each example has both: -- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches invalid Loader exports that hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md). Keyless process smokes use `@deepseek-ai/dsh-loader-smoke` for Loader launch resolution; terminal tests wrap that launch in a pseudo-terminal. Tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples/<agent>/` leaf. Map a package-owned config to `examples/<agent>/tests/fixtures/<group>/<package>/cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..9eb71d8948 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -236,7 +236,7 @@ const SCENARIOS: Scenario[] = [ // and then `migrate:packed-session-fixtures`, which canonicalizes the live // log's eager-drain-packed rows into the maximal-run layout replay produces. // The recorded fixture's `request/header` config and `request/context` are - // normalized to the replay-produced minimal shape (the live adapter logs + // normalized to the minimal fields produced during replay (the live adapter logs // model capabilities like maxTokens/reasoningEffort that llm-replay has no // data for), and its tool-result paths are canonicalized to `/` separators. { diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index b94a78aa30..9f2bbf5f96 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -20,7 +20,7 @@ import { cleanupAcpExampleTest } from './cleanup.ts' * * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as * an ACP subprocess and drive initialize + session/new — the real-Loader-path - * guard (postmortem 0001) for THIS tree's export shapes, including the + * guard (postmortem 0001) for THIS tree's exports, including the * sandbox executor AND the approval service. No prompt is sent, so neither the * model nor a sandbox runner is ever exercised. * @@ -74,8 +74,8 @@ function launchExampleAcpAgent( requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) - // The scripted machine policy selects the requested option; an - // unexpected request shape cancels (fail closed, never grants). + // The scripted machine policy selects the requested option. If that + // option is absent, the policy cancels (fail closed, never grant). if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, @@ -107,7 +107,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa const { client } = spawned // A dummy key boots the adapter; no prompt is ever sent, so no model call // and no sandbox runner probe happen. This drives the fiber tree the same - // way an ACP caller would, which catches a broken export/inject shape. + // way an ACP caller would, which catches broken exports or injection. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) expect(init.protocolVersion).toBe(PROTOCOL_VERSION) expect(init.agentCapabilities).toEqual({ diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index ba75ce9294..f855c958bf 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -67,7 +67,7 @@ describe('headless-agent keyless smoke', () => { it('keeps the checked-in prepared wrapper identical to the generator output for its manifest', async () => { // The fixture claims "Generated by dsh-plugin-prepare"; this pin makes the // claim true — a wrapper-template change fails here until the fixture is - // regenerated, so the assembled smoke can never exercise a stale shape. + // regenerated, so the assembled smoke can never exercise stale generated fields. const fixture = fileURLToPath(new URL('./fixtures/repository-plugin/', import.meta.url)) const root = await mkdtemp(join(tmpdir(), 'dsh-fixture-drift-')) try { diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index 7e8e5248e4..d1de74a676 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: 792bb31b668b427c8734286878a9ec98071190d8 -README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 +README.md: 58da672030eaf2ddf70ee92d506de300efcd9650 +README.zh.md: 0a2f109f9458ec7e1aba50e7fc9b6fd0fca15dbd diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 792bb31b66..58da672030 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -10,7 +10,7 @@ These third-party configurations are provided as interoperability examples only. DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp__<serverName>__<tool>`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. -The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. +The stdio bridge deliberately removes ambient variables whose names usually identify credentials and all `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. ## Choose one @@ -95,7 +95,7 @@ A new DSH session is required; a Host restart is not. Restart or HMR is needed o ## Bring another MCP server -Copy the same generic shape and use a unique `id` and `serverName`: +Copy the same entry fields and use a unique `id` and `serverName`: ```yaml - insert: diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 51020f5288..0a2f109f94 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -10,7 +10,7 @@ DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp__<serverName>__<tool>` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 -stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 +stdio 桥接器在启动子进程前会主动移除环境中名称通常表示凭据的变量和所有 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 ## 选择一个 @@ -95,7 +95,7 @@ Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工 ## 接入其他 MCP 服务器 -复制相同的通用结构,并使用唯一的 `id` 和 `serverName`: +复制相同的条目字段,并使用唯一的 `id` 和 `serverName`: ```yaml - insert: diff --git a/examples/web-cordis/README.i18n.yaml b/examples/web-cordis/README.i18n.yaml index d2004a499c..d0cc1f4992 100644 --- a/examples/web-cordis/README.i18n.yaml +++ b/examples/web-cordis/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/web-cordis/README.md -README.md: 21fe0a210b2e591a96dc254014a0f91ed9afa2ba -README.zh.md: b3fecb4312dbcbaeff460a21f1d2db6b5288f9ad +README.md: 5d46db7e9d4416f0e6327f7e119bc863ffbdc8a6 +README.zh.md: 9074d8a2dc19b851d85e4fe037f588f70d8da4c7 diff --git a/examples/web-cordis/README.md b/examples/web-cordis/README.md index 21fe0a210b..5d46db7e9d 100644 --- a/examples/web-cordis/README.md +++ b/examples/web-cordis/README.md @@ -18,4 +18,4 @@ Start the ACP automation server instead: pnpm run demo:cordis acp ``` -Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/self-modification/tool-cordis/README.md) owns the tool, lifecycle, and safety contracts. +Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/self-modification/tool-cordis/README.md) defines the tool arguments, lifetime, cleanup, and safety contracts. diff --git a/examples/web-cordis/README.zh.md b/examples/web-cordis/README.zh.md index b3fecb4312..9074d8a2dc 100644 --- a/examples/web-cordis/README.zh.md +++ b/examples/web-cordis/README.zh.md @@ -18,4 +18,4 @@ pnpm run demo:cordis pnpm run demo:cordis acp ``` -这两条命令都需要 `DEEPSEEK_API_KEY`。工具、生命周期和安全约定由 [Cordis 工具参考](../../packages/self-modification/tool-cordis/README.md)定义。 +这两条命令都需要 `DEEPSEEK_API_KEY`。[Cordis 工具参考](../../packages/self-modification/tool-cordis/README.md)定义了四类约定:工具参数、存续时间、清理行为和安全性。 diff --git a/native/landlock-run/AGENTS.md b/native/landlock-run/AGENTS.md index 29bf66fa9d..48f1267c63 100644 --- a/native/landlock-run/AGENTS.md +++ b/native/landlock-run/AGENTS.md @@ -1,10 +1,10 @@ # AGENTS.md -This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and speaks its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. +This directory builds `landlock-run`, a Landlock self-restrict-then-exec launcher: a small, auditable confinement binary distributed as prebuilt per-platform npm packages, plus the thin JS entry package that resolves it and implements its CLI contract. It belongs to the repository's root pnpm workspace and lockfile. The main repository owns native CI, tarball assembly, verification, and npm publication; keep package-family changes coordinated with harness consumers in the same repository. ## Pre-release stance -The project is pre-1.0. Prefer the correct public shape over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. +The project is pre-1.0. Prefer the correct public API over compatibility shims: if a package name, exported field, layout, or contract detail is wrong, rename it and update all references in the same change. Do not add deprecated aliases unless a stable release already needs them. ## Runtime safety rules @@ -47,4 +47,4 @@ pnpm test # entry tests everywhere; launcher tests need linux + built ## Documentation -User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implemented shape belongs in [docs/architecture.md](docs/architecture.md). +User-facing docs are English. Keep the README focused on install, usage, and support status; durable design decisions belong in docs/ alongside the code, and the current implementation belongs in [docs/architecture.md](docs/architecture.md). diff --git a/native/landlock-run/docs/architecture.md b/native/landlock-run/docs/architecture.md index 33c71d6893..72d2f85cff 100644 --- a/native/landlock-run/docs/architecture.md +++ b/native/landlock-run/docs/architecture.md @@ -9,7 +9,7 @@ The family is one entry package plus per-platform binary packages: - **Entry package** (`@deepseek-ai/node-addon-landlock-run`): ESM JavaScript. Owns the tool's CLI contract — path resolution (`launcherPath`), the functional probe (`probe`), grant-argv construction (`grantArgs`), and the contract constants. Ships the C source in its tarball for auditability. Lists every platform package as an `optionalDependency`. - **Platform packages** (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`): one prebuilt static binary under `bin/`, a `prebuilds.json` declaring it, and no JavaScript at all. npm's `os`/`cpu` fields select the matching one at install time; the entry package resolves it to a file path — there is nothing to import. -Because the contract parser and the binary version together in one family, probe-parsing drift against the binary is structurally impossible — the failure mode the split exists to prevent. +Because the CLI parser and binary are versioned together in one package family, the parser cannot fall behind that binary version. Preventing that mismatch is why the package split exists. There is no shared loader package: platform packages have nothing to load. If a second tool ever needs shared JS, extract it then, not preemptively. diff --git a/native/landlock-run/docs/packaging.md b/native/landlock-run/docs/packaging.md index ec459eb655..16ee74de97 100644 --- a/native/landlock-run/docs/packaging.md +++ b/native/landlock-run/docs/packaging.md @@ -1,6 +1,6 @@ # Packaging -The package family uses the same broad shape as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend dimension — each platform package carries exactly the static executables its `prebuilds.json` declares. +The package family uses the same layout as native packages such as esbuild: one JS entry package plus platform optional packages. Unlike Node addons there is no ABI or backend division — each platform package carries exactly the static executables its `prebuilds.json` declares. ## Published packages diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 6cff1b6327..8f669e253a 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -2,17 +2,17 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions). -- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Plugin exports:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). -- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). +- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external services or nondeterministic inputs and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). -- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. -- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). +- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement point; otherwise fold it while preserving rollback, callback containment, and quiescence. +- **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. -- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. -- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. +- **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. +- **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. - **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. - **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal. - **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md). diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 240a707c65..dc299f49c3 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -125,7 +125,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { proc = this.startArgv(spec, confined.argv) } catch (error) { // LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async - // `done` rejection; this covers alternatives that throw that shape synchronously. + // `done` rejection; this covers alternatives that throw the same error synchronously. if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) { throw new SandboxUnavailableError(mode, String(error)) } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 5d51b559d4..cec63092de 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0 -README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c +README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 +README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 49c75bac1b..be03bceb39 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -14,12 +14,12 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it | +| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | | `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | | `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of rows from the same file and patch layers is preceded by a `# ==` comment naming them, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | @@ -57,4 +57,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **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. -- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps. +- **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 93adc52c11..1016548671 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -14,12 +14,12 @@ | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 | +| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | | `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来自同一文件且经相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | @@ -57,4 +57,4 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [` - **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 -- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 +- **用户 patch 会替换匹配到的整个配置**:按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 256ea34299..fa23e6f8da 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -306,7 +306,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ /** * Parse one loader patch list: a top-level YAML array of * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and - * `insert` lists, `!!js` expressions allowed). Every shape failure throws, + * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, * because a patch file that cannot be applied at all is a misconfiguration; a * single patch whose target row is absent stays a per-entry Loader warning, so * one overlay shared across surfaces does not have to match every tree. @@ -397,12 +397,12 @@ export function renderConfigDump( throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`) } const baseLabel = basename(absoluteConfigPath) - // The YAML boundary yields untyped rows; the include validates entry shape + // YAML parsing yields untyped rows; the include validates each entry // at mount, and the dump prints whatever the file holds, so `EntryOptions` // here is structural trust in the same file `boot()` would include. const base = parsed as Parameters<typeof applyEntryPatches>[0] - // snapshot_k = ONE application of layers 1..k flattened — boot's exact call - // shape for that prefix. snapshot_N is therefore the mounted composition. + // snapshot_k = ONE application of layers 1..k flattened, using the exact + // arguments boot passes for that prefix. snapshot_N is the mounted composition. // The patches are cloned per call: applyEntryPatches detaches the entry // list but pushes `insert` rows by reference from the patch list, so // sharing patch objects across snapshot calls would leak a later diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index d951a87cf0..de44d58882 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -267,7 +267,7 @@ export function readProfileManifest(binName: string, dir: string): ProfileManife } catch (error) { throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`) } - // File boundary: the shape check below validates what the parse type asserts. + // The field checks below validate the file data before trusting the parse type. const parsed = JSON.parse(raw) as ProfileManifest | null if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 395b8b2cf6..f8f5e10856 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -14,24 +14,24 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07- 4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). -7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. +7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for. ## Reactive read and contract-currency discipline -How live data reaches render code, and what may cross a business boundary: +How live data reaches render code, and what UI domains may share: 1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. 2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. 3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration. -4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots). +4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. ## Export discipline (client plugin packages) -The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments): +The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments): -1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. +1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile. 3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. @@ -44,7 +44,7 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): 1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. -2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. +2. **Render machinery** (`web-react`, shell-only glue): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all. 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. Non-negotiables across the layers: @@ -62,7 +62,7 @@ Non-negotiables across the layers: ## Directory regime (plugin packages) -One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. +One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. ## Styling @@ -99,9 +99,9 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com ## New component checklist -1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. +1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. -3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery. +3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. 4. Tokens only in CSS; Chinese product copy; English comments. 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index f9ce3024f1..da3e6eed6d 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/hmr/README.md -README.md: 454c03cc3cd11722943efd025d164d9ca8233d25 +README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2 README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index 454c03cc3c..9228292547 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request. - **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out. - **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically. -- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary. +- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it. diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 86d87df60f..c2c8d4e942 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/modules/README.md -README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060 -README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110 +README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace +README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 1d327c7252..7b4c9b72e7 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change. +- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change. - **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index a97672b909..6420f6324f 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -20,5 +20,5 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包, ## 已知限制与暂缓事项 -- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。 +- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。 - **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`);loader 只在每条记录中登记其拥有的样式标签 id。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 82070f9206..d53be39e0d 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -43,7 +43,7 @@ declare module 'cordis' { } } -/** package.json `dshClient` declaration shape (file boundary — validated field by field). */ +/** package.json `dshClient` declaration fields, validated one by one after reading the file. */ interface DshClientDeclaration { inject?: string[] platform: string @@ -138,7 +138,7 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi const fallback = (client as Record<string, unknown>).default if (typeof fallback === 'string') return fallback } - throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`) + throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`) } /** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */ diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 29b5a5f3b6..d7252cd195 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ export const CLIENT_EXTERNALS: readonly string[] = [...PLATFORM_MODULES, RUNTIME const REPOSITORY_ROOT = fileURLToPath(new URL('../..', import.meta.url)) -/** Rebase a physical lib-relative source onto the browser's repository-shaped URL tree. */ +/** Rebase a physical lib-relative source onto a browser URL that mirrors the repository directories. */ function browserSourcePath(source: string, sourcemapPath: string): string { if (!source.startsWith('.')) return source const physicalSource = resolvePath(dirname(sourcemapPath), source) @@ -71,7 +71,7 @@ function browserSourcePath(source: string, sourcemapPath: string): string { * plus the browser client bundle. Client packages emit both halves during the * Client pass by default; packages needed for Host reflection may opt into the * earlier Host pass. A package-level tsdown.config.ts REPLACES the root - * workspace shape, so the lib half must be restated here — dropping it leaves + * workspace layout, so the lib half must be restated here — dropping it leaves * the package without lib/index.js and the host Loader cannot import its node * half. * @param id - plugin id (package name), stamped into the __ModuleLoader__.load @@ -253,8 +253,8 @@ function clientConfig(id: string, entry: string): UserConfig { outputOptions: { entryFileNames: 'client.js', // The map is served from /plugins/<scoped-package>/client.js.map. The - // browser resolves its local sources back into the repository-shaped - // /packages/<group>/<package>/src tree; sourcesContent keeps them usable + // browser resolves its local sources back into URLs that mirror the + // /packages/<group>/<package>/src directories; sourcesContent keeps them usable // without exposing that tree as an HTTP route. sourcemapPathTransform: browserSourcePath, banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`, diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index acf611bbca..58d54302ad 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-command/README.md -README.md: db785e769cb40235a77d05b4b66d096896a35d8a -README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a +README.md: e49ce89804886a11f102fcaf60316e8044965c10 +README.zh.md: 8bd5afd7d0a173980f476cb96f8115525602b0b4 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index db785e769c..e49ce89804 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md). +Client command API (`ctx.command`): the session-keyed command-directory cache, the `/` command source with `matchSpace`/`matchEnter` decision hooks, three-kind dispatch (`execute` / `popupSelect` / `leadingInput`), and popupSelect registration for business packages. The [web command Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md) records the decision. -`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute. +`src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. @@ -12,7 +12,7 @@ Menu queries fuzzy-match ordered, case-insensitive subsequences of command names `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. -The `/client` export surface is the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the frozen contract types; the shell component itself is internal to the overlay registration. +The `/client` entrypoint exports the plugin body (`apply`/`inject`), `CommandService`, the directory and popup classes with their state types, and the fixed contract types; the shell component itself is internal to the overlay registration. ## Model Experience diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index f0f23319a8..8bd5afd7d0 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。约定:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。 +客户端命令 API(`ctx.command`):以会话为 key 的命令目录缓存、带 `matchSpace`/`matchEnter` 决策钩子的 `/` 命令 source、三类派发(`execute`/`popupSelect`/`leadingInput`),以及面向业务包的 popupSelect 注册。[Web 命令 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)记录了这项决策。 -`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。 +`src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有,业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claim(space / 带参 Enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 @@ -12,7 +12,7 @@ `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 -`/client` 导出表层是插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的约定类型;壳组件本身是 overlay 注册的内部实现。 +`/client` 入口导出插件主体(`apply`/`inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及固定的约定类型;外层组件本身是 overlay 注册的内部实现。 ## 模型体验 diff --git a/packages/client/ui-command/src/client/directory.ts b/packages/client/ui-command/src/client/directory.ts index a7cdca7fd8..0e4fd916b2 100644 --- a/packages/client/ui-command/src/client/directory.ts +++ b/packages/client/ui-command/src/client/directory.ts @@ -1,7 +1,7 @@ /** * Command-directory cache keyed by session: one entry per served catalog — * every session is agent-backed, so `command.list({sessionId})` is the only - * address shape. Each entry keeps the single-flight / soft-hard invalidation + * request fields. Each entry keeps the single-flight / soft-hard invalidation * / epoch-guard behavior of the original global cache; the session-key axis * is the only extra dimension. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 13edd00c37..6ebee98c95 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -516,7 +516,7 @@ export function InputBar({ } pushPlain(draft.length) if (deco.hint !== null) { - // Claim tokens are shaped `/name ` (trailing space); trim to the bare name. + // Claim tokens have the `/name ` format (trailing space); trim to the bare name. const commandName = input?.claim?.token.slice(1).trim() ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 2c041e0eae..d9b185b8fd 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -38,7 +38,7 @@ const NS = 'goal' /** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] -/** Map one generated Remote call, including synchronous namespace lookup failures, onto the strip's inline-render shape. */ +/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> { try { await invoke() diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index c2a8741464..5bf2c744a3 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -7,7 +7,7 @@ * Per-session storage follows the client service pattern (SlashService / * CommandService): a lazy service-internal map whose entry is deleted by the * owning scope's disposer. The host `dsh-scope` ScopedLayers registry does - * not transplant here: it derives scope from the host carrier mechanism + * does not belong here: it derives scope from the host carrier mechanism * (object-keyed), while client scopes tag contexts with branded SessionId * strings, and it models global+shadow named registries — this is a * per-session singleton with no global layer to merge. diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ec20685def..c03e091a5e 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 9841ced87ae345c685c59e96a7b9088d474181f5 -README.zh.md: bb1445fbc8093d356ce838948b8338fa04919063 +README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec +README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 9841ced87a..e0c5728d47 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation @@ -31,5 +31,5 @@ None; this package neither assembles nor sends a provider request. - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. -- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index bb1445fbc8..fe11e6cdd1 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 @@ -31,5 +31,5 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, - **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 -- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 +- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 50cbad2549..16fb544dc0 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -196,7 +196,7 @@ let loadCount = 0 * Subscribe to lazy-grammar load completions; `listener` fires after a * {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a * caller that rendered its plain fallback while the grammar loaded can - * re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with + * re-highlight. Uses the `useSyncExternalStore` subscribe signature; pair it with * {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function. * @param listener - invoked (no args) on each grammar-load completion. * @returns a disposer that removes the listener. diff --git a/packages/client/ui-question/src/client/contract/slots.ts b/packages/client/ui-question/src/client/contract/slots.ts index 8f9d9f8c75..e8bada18b0 100644 --- a/packages/client/ui-question/src/client/contract/slots.ts +++ b/packages/client/ui-question/src/client/contract/slots.ts @@ -86,8 +86,8 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u /** * Question domain face over the carrier: render identity and questions - * transparently forwarded; answer/cancel own the wire encoding (the ok value - * shape and the cancelled error) and turn a rejected carrier receipt into a + * transparently forwarded; answer/cancel own the wire encoding (the success + * fields and the cancelled error) and turn a rejected carrier receipt into a * thrown error. Components mint one per carrier via useMemo (never inside a * select — a per-dispatch mint would churn identity and break memoization). */ diff --git a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts index 08d6389686..1c9393e6f4 100644 --- a/packages/client/ui-tool/src/client/tool/models/search-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/search-card-model.ts @@ -77,9 +77,9 @@ export interface SearchCardModel { /** * Whether every file group in a matches view is structurally valid: the wire * frame carries `shape` and `card` as strings the host schema checks, but not the - * grouped shape, so a version mismatch or loose producer could deliver + * grouped `files` fields, so a version mismatch or loose producer could deliver * `shape: 'matches'` with a missing or malformed `files`. Rendering that would - * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the + * crash {@link SearchBlock} at `.reduce`/`.map`; invalid fields select the * generic path instead. * @param files - the candidate `files` field off the untrusted result view. * @returns whether `files` is a valid {@link SearchFileGroup} array. @@ -136,12 +136,13 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { // The recovery footer only matters when the tool capped the result: an // uncapped card holds every match/path, so the raw text adds nothing the card // does not already show. When capped, the raw result's `Full … stored at …` - // locator is the only path to the dropped rows, so surface it. + // locator is the only way to retrieve the omitted rows, so include it. const recovery = result.truncated ? flattenContent(block.content) : undefined if (result.shape === 'matches') { // `files` rides the untrusted wire frame: the host schema checks `card`/`shape` - // strings but not the grouped shape, so validate it before SearchBlock, which - // would crash on a missing/malformed `files`. An invalid shape falls to generic. + // strings but not the grouped `files` fields, so validate them before + // SearchBlock, which would crash on a missing or malformed `files`. + // Invalid fields select the generic view. if (!isValidFiles(result.files)) return null return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 8f45bf3a49..880938567f 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -21,8 +21,8 @@ function isAnswer(value: unknown): value is AnswerEntry { return typeof value === 'object' && value !== null } -/** Answered-count summary off the result JSON (a skipped question has - * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */ +/** Answered-count summary from the result JSON (a skipped question has + * empty `selected` and no `custom`); null when answer fields are invalid. */ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null { let parsed: unknown try { diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 2556cfd8b4..111189aede 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -41,7 +41,7 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null { // Mid-stream truncation or malformed model JSON: fall back to the generic summary. return null } - // Valid JSON with an invalid shape (null root, non-array todos, null items — + // Valid JSON with invalid todo fields (null root, non-array todos, null items — // a rejected tool/call retains such args verbatim): same generic fallback. if (typeof parsed !== 'object' || parsed === null) return null const todos = (parsed as { todos?: unknown }).todos diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index cbe0d0ac55..67a8419bd3 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -234,7 +234,7 @@ export interface PendingCall { reject(error: Error): void } -/** Constructor shape for one program-visible binding rejection class. */ +/** Constructor type for one program-visible binding rejection class. */ export type BindingErrorConstructor = new (memberName: string, message: string) => Error /** diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index ec1fa015ed..ffd3fd22a8 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -32,13 +32,13 @@ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFa case 'assistant/message': case 'tool/call': case 'tool/result': - fail('time-context reading must be appended at a prompt boundary') + fail('time-context reading must be appended during prompt assembly') break default: break } } - fail('time-context reading must be appended at a prompt boundary') + fail('time-context reading must be appended during prompt assembly') } /** Validate one plugin-attributed time reading against its session position and timestamp. */ diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 59ffa66a89..b5f0385b3f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -129,20 +129,20 @@ describe('time-context invariants', () => { const session = preparing(1, 2) session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) - .toThrow(/at a prompt boundary/) + .toThrow(/during prompt assembly/) }) - it('rejects a reading outside a prompt boundary', async () => { + it('rejects a reading outside prompt assembly', async () => { const ctx = await setup() const ended = preparing(1, 1) ended.append('step/end', { turn: 1, step: 1 }) - expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/) + expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/during prompt assembly/) const notEntered = Session.create(SessionId('time-invariant-turn-only')) notEntered.append('turn/start', { turn: 1 }) - expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/at a prompt boundary/) + expect(() => { ctx.emit('session/event', notEntered, event(reading())) }).toThrow(/during prompt assembly/) expect(() => { ctx.emit('session/event', Session.create(SessionId('time-invariant-empty')), event(reading())) - }).toThrow(/at a prompt boundary/) + }).toThrow(/during prompt assembly/) }) it.each([ diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index df32eaf80c..f27b2f4622 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -503,8 +503,8 @@ export class Session { /** * Restore a detached session by taking ownership of fresh persistence values. - * Storage shape, event envelopes, sequence continuity, surface transitions, - * and header fields are validated before the graphs are frozen in place. + * The storage format, event envelopes, sequence continuity, surface transitions, + * and header fields are validated before the restored objects are frozen. * @param id - restored session identity. * @param seed - fresh detached events whose ownership is transferred. * @param header - fresh detached metadata whose ownership is transferred. diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 6f1757ff28..23b5936e08 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -184,7 +184,7 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly; known names + * Invalid fields fail at load and unknown names fail at assembly; known names * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 38a9c2ede3..0c5e1fee44 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: f3d1b4741c7fde64669794d079c36a18e633c0c1 -README.zh.md: d3054372095ef0cfdabc6cf80e0faa41a3b12d4c +README.md: 2c9833c3505c765283559590c8bc28b3c2077e2e +README.zh.md: d7766b432c5a319d214da80e3df438489519be92 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f3d1b4741c..2c9833c350 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -36,7 +36,7 @@ Cancellation is cooperative and quiescent. Every typed invocation supplies a cal ### Live events -The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. +The live registry pipeline has three transformable waterfalls, then the definition-owned content finalizer, then the observe-only `tools/result` event; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure containment contracts live in the generated region of [tools.md](../../../docs/subsystems/tools.md#cordis-surface), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types @@ -120,7 +120,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. -- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. +- **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. ### Parallel execution @@ -146,7 +146,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode API. The instructions and SDK block match the loaded runtime's language; the TypeScript version (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python version (for any runtime reporting `language: 'python'`) has the same operations and types in Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`). ##### Code Mode SDK instructions diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d305437209..d7766b432c 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -36,7 +36,7 @@ tools: ### 实时事件 -实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后到达仅观测的 `tools/result` 边界;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和故障收容约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 +实时注册表流水线先经过 3 个可变换的 waterfall,再经过由定义拥有的内容终结器,最后发布仅供观测的 `tools/result` 事件;注册表变更有意作为不过滤的共享状态通知。确切签名、分发 mode、作用域筛选和失败隔离约定位于 [tools.md](../../../docs/subsystems/tools.md#cordis-surface) 的生成区块,完整顺序则在生成的[工具执行流水线](../../../docs/tool-execution-pipeline.md)中可视化。`tools/result` 是实时事件;名称相近的 `tool/result` 是 agent loop 随后追加的持久会话事件。 ### 关键类型 @@ -120,7 +120,7 @@ ctx.tools.register(defineTool({ - **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 - **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 -- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 +- **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该上限。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 ### 并行执行 @@ -146,7 +146,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e #### 模型看到的内容 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块(TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode API。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 版本(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)),Python 版本(用于任何报告 `language: 'python'` 的运行时)以 Python 语法提供相同操作和类型(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`)。 ##### Code Mode SDK 说明 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 4f094e4ade..e7c13b2168 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -28,7 +28,7 @@ export const SDK_SECTION_ORDER = 150 * strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the * semantics the same language's SDK instructions promise, so the model never - * receives a TypeScript-shaped schema beside a Python SDK (or vice versa). + * receives a TypeScript schema beside a Python SDK (or vice versa). */ interface RunCodeFlavor { /** The tool `description` the model sees for this language. */ @@ -338,8 +338,8 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge exec.signal.addEventListener('abort', onOuterAbort, { once: true }) let dispatches = 0 - // The per-run scheduler, reusing the NATIVE concurrency contract through - // the registry's staged view (the loop scheduler's own boundary) — and the + // The per-run scheduler uses the registry's staged interface and follows + // the same concurrency rules as the native loop. It also follows the // native loop's SEQUENCING: every ordered stage (the dispatch-start // append, prepare = pre-execute/guards, finalize/finish = post-execute, // context deferral, the settle append) runs inside ONE driver lane, so @@ -369,7 +369,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge } const pendingQueue: PendingDispatch[] = [] const inFlight = new Set<Promise<void>>() - /** Tracked settle-event side work (log shaping + append), drained at run settlement. */ + /** Tracked settle-event side work (log-content listener + append), drained at run settlement. */ const logWork = new Set<Promise<void>>() const commitQueue: PendingDispatch[] = [] let exclusiveActive = false @@ -394,7 +394,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge driverRun = (async () => { try { for (;;) { - // Arm before inspecting state so a settle or submission landing + // Create the wakeup promise before inspecting state so a settle or submission arriving // between the checks and the await below cannot be lost. const signal = new Promise<void>((resolve) => { wake = resolve }) const commitHead = commitQueue[0] @@ -449,7 +449,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge // entries, awaits the live pool, and drains the ordered commit lane — // including a commit already in progress when the program returned. await drive() - // Every settle's shaped append lands inside the open run_code turn + // Every settle event is appended inside the open run_code turn // (tasks self-remove on settlement). while (logWork.size > 0) await Promise.allSettled([...logWork]) } @@ -483,10 +483,10 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge | { kind: 'post-result' | 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } | undefined const settle = (result: ToolExecutionResult): void => { - // The program gets its value NOW: log shaping (e.g. a spill - // backend) must never delay the binding or occupy a dispatch - // slot. The shaped append is tracked side work; the run's - // settlement drains logWork so every settle event still lands + // The program gets its value NOW: the log-content listener (for + // example, a spill backend) must never delay the binding or occupy + // a dispatch slot. The event append is tracked side work; the run's + // settlement drains logWork so every settle event is still appended // inside the open turn (shapeDispatchLog is contained, so this // chain cannot reject). resolve(result.isError @@ -495,9 +495,9 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge const agent = exec.agent if (agent === undefined) return const task: Promise<void> = (async () => { - // The durable copy may be reshaped (e.g. spilled to a preview + - // locator) by the log-shaping waterfall; the program's value - // and the model contract are untouched. + // The listener may replace the durable copy with a preview and + // locator; the program's value and model-visible result are + // untouched. const logged = await shapeDispatchLog({ exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result @@ -560,16 +560,16 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge for (const context of result.additionalContexts ?? []) { exec.deferContext(context) } - // Like the context forwarding above, cross-boundary facts travel - // on the nested result and the composite forwards them: only a - // successful nested result can carry the terminal marker + // The composite forwards `additionalContexts` above and + // `concludesTurn` here from the nested result. Only a successful + // nested result can carry the terminal marker // (ToolExecutionFailure types it never), so a policy-converted // failure cannot stop the turn through a recovering program. if (result.concludesTurn) exec.concludeTurn() settle(result) - // Backpressure on the shaped-append side channel: pending log - // tasks (each retaining a full result while a slow backend - // stores it) are bounded by the pool cap — beyond it the + // Backpressure on pending event-append tasks: each task retains + // a full result while a slow backend stores it, so the pool cap + // bounds their count. Beyond the cap, the // ordered lane waits, so later sub-calls cannot start and // pending I/O/memory cannot grow without bound. while (logWork.size > maxParallel) await Promise.race(logWork) @@ -578,7 +578,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge wakeup() void drive() }) - // A budget expiry or outer cancel that lands while this call was in + // A budget expiry or outer cancel that occurs while this call was in // flight already aborted the dispatch; stop the program now rather // than hand it a result from a run that is over. if (runOver()) { @@ -661,7 +661,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge Object.defineProperty(definition, 'parameters', { enumerable: true, // Recompile through the same spec→schema projection defineTool used, so - // the emitted shape can never drift from the validated one. + // the emitted schema always matches the validated specification. get: () => parameterSchemaSpecToJsonSchema({ code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription }, description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index a44df0863f..a1653e7d16 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -160,13 +160,14 @@ declare module 'cordis' { */ 'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision> /** - * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before - * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the * content unchanged; a listener may return replacement blocks (e.g. the * spill policy's preview + locator for an oversized text result). Only the * logged copy is affected — the program already received the complete * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the unshaped content. + * the bridge falls back to logging the original settled content. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall @@ -1183,8 +1184,8 @@ export class ToolRegistry extends Service { /** * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch * and return the content the bridge should log on `tool/code-dispatch`. - * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. Private: + * Contained: when a listener throws, the method logs the original settled + * content; that failure must not fail the dispatch or omit the settle event. Private: * the ONE consumer is the `run_code` bridge this registry constructs, which * receives it as a capability parameter (the `requireRuntime` idiom) — the * waterfall, not this invoker, is the public extension point. @@ -1196,7 +1197,7 @@ export class ToolRegistry extends Service { () => Promise.resolve(dispatch.content), ) } catch (error: unknown) { - this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`) + this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) return dispatch.content } } diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 9b6ca88d93..9191bcfbfa 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -7,7 +7,7 @@ * * Unsupported or misplaced keywords reject rather than being accepted without * enforcement. Consumers that require an object root apply - * {@link assertObjectJsonSchema} at their own boundary. + * {@link assertObjectJsonSchema} before accepting input. * @module dsh-tools/json-schema */ @@ -25,7 +25,7 @@ type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'> /** * One raw JSON Schema node in the enforced subset. The optional fields express - * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid + * the external wire schema; {@link assertSupportedJsonSchema} rejects invalid * combinations before a caller treats the node as trusted. */ export interface JsonSchemaNode { diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index e4b241b75f..4898ec80e1 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -733,7 +733,7 @@ export function jsonSchemaToPy(schema: unknown): string { /** The fixed model-facing usage contract rendered above the declarations. */ const SDK_INSTRUCTIONS = `## Writing code for run_code -Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: +Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing argument and return types — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program: - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 34758b840f..1794e7e36e 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -59,7 +59,7 @@ async function setup(options: SetupOptions = {}) { return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } } -/** Mint one production-shaped agent scope that can register scoped tool policy. */ +/** Mint an agent scope configured like production that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { const agent = { id: SessionId(name) } as Agent let scope!: Scope @@ -407,7 +407,7 @@ describe('mode-aware wire contribution', () => { }) it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { - // Any reader of the definition without a mounted runtime lands here; the + // Any reader of the definition without a mounted runtime uses this fallback; the // shipped one is the tool-catalog generator, which boots the registry under // `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime // returns undefined there, so the flavor getter degrades to the TS default @@ -663,7 +663,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { expect(stages).toEqual(['post-enter:writer', 'post-exit:writer']) }) - it('run settlement drains a commit already in progress: the settle event lands inside the turn', async () => { + it('run settlement drains a commit already in progress: the settle event is appended inside the turn', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const gated = registerGated(ctx, 'safe_read', true) const { agent, events } = fakeAgent() @@ -921,10 +921,10 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) - it('a throwing tools/code-dispatch-log listener is contained: the unshaped content is logged', async () => { + it('a throwing tools/code-dispatch-log listener is contained: the original settled content is logged', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) registerEcho(ctx) - ctx.on('tools/code-dispatch-log', () => { throw new Error('shaper exploded') }) + ctx.on('tools/code-dispatch-log', () => { throw new Error('log-content listener failed') }) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { const value = await request.bindings[0]!.functions.echo!({ value: 'x' }) @@ -1593,7 +1593,7 @@ describe('per-agent presentation', () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) // The preset's standing scope declares once; the agent only PARENTS to it - // (the per-preset standing-mount shape — no per-agent declaration at all). + // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:code-like') standing.scope.ctx.tools.presentAs('code') const joined = await mintAgentScope(ctx, 'joined-agent') diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index cdb4bd6eb8..e7800253f3 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -344,7 +344,7 @@ export class CredentialsLocal extends Credentials { /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same reviewed contract as settings-local, deliberately mirrored (prefer symmetry for parallel values); the two providers own different documents and - failure policies, so extracting the shape would couple their teardown + failure policies, so extracting a shared helper would couple their teardown semantics across packages for a handful of lines. */ /** Queue one exclusive document operation behind every earlier one. */ private enqueue<T>(operation: () => Promise<T>): Promise<T> { diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index d6f23acf04..f10fd8fb47 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md -README.md: bb0ac1d5f1f3dbfd0f021d5fbebbab13dcee37ee -README.zh.md: d511df72284b047626ece628b702a2ac8d2f873d +README.md: e27582e5603e430e1467cb6e47c6f12bd1a0b886 +README.zh.md: 788c6273f1f2f30795d8d1ea09b481a85b88a2f6 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index bb0ac1d5f1..e27582e560 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -38,6 +38,6 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Control state shares the sandbox user's UID** — E2B runs every command as the same default user, so `0700`/`0600` modes cannot isolate `.dsh-e2b` control files from concurrently running sandbox processes. A background process could rewrite `pid`/`exit-code` or read a not-yet-consumed `environment` file. The adapter validates published values and refuses group ids whose negative form is unsafe to signal (`<= 1`), but real isolation needs an E2B per-command user or an out-of-band control channel. - **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol. - **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. A same-UID untrusted process already in the sandbox could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive to close the gap. -- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`. +- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values equal to `128 + signal`. - **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence. - **Linux utility and E2B transport semantics are assumed** — there is no Windows, escaped-session recovery, or network-partition fidelity layer. diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index d511df7228..788c6273f1 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -38,6 +38,6 @@ E2B 默认基础镜像提供该适配器调用的运行时和 Bash/GNU 工具: - **控制状态与沙箱用户同 UID**:E2B 以同一默认用户运行每条命令,因此 `0700`/`0600` 权限无法把 `.dsh-e2b` 控制文件与并发运行的沙箱进程隔离开。后台进程可以改写 `pid`/`exit-code`,或读取尚未被消费的 `environment` 文件。适配器会验证已发布的值,并拒绝取负后不安全的进程组 ID(`<= 1`),但真正的隔离需要 E2B 提供按命令用户或带外控制通道。 - **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。 - **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。一个已在沙箱内运行的同 UID 不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语才能弥合该缺口。 -- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。 +- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括等于 `128 + signal` 的值。 - **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。 - **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、逃逸会话恢复或网络分区的保真层。 diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md index e9cb3d2b51..ee6bf61598 100644 --- a/packages/experimental/AGENTS.md +++ b/packages/experimental/AGENTS.md @@ -2,10 +2,10 @@ These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. -- All Cordis plugin packages whose whole public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. +- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. - Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. - Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. -- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define narrower internal contracts but make no public release promise. +- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise. - Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. - Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. - Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/fs/fs-policy/src/types.ts b/packages/fs/fs-policy/src/types.ts index 9ee742a7f7..f2bd7187d3 100644 --- a/packages/fs/fs-policy/src/types.ts +++ b/packages/fs/fs-policy/src/types.ts @@ -1,9 +1,9 @@ /** * Vocabulary for the fs-policy plugin: the minimal execution-context - * shape used to derive an observed-state owner by narrowing the opaque `object` + * fields used to derive an observed-state owner by narrowing the opaque `object` * actor the `fs/*` events carry. * - * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit request types) is * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state * owner structure on top of it. * @@ -12,10 +12,10 @@ /** * Minimal structural view of a tool execution the policy plugin needs to derive - * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies - * this shape, so the tool passes its `exec` straight through as the opaque - * `object` actor on the `fs/*` events; this plugin narrows that actor to this - * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` contains + * these fields, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to + * `FsPolicyExec` without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. * * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 49548499ac..c7e8ec7ee4 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -118,7 +118,7 @@ export function buildGrepCommand(input: GrepInput): string[] { /** * The uniform malformed-output failure: raw `rg --json` is an internal - * transport, so a shape surprise is a search failure, not a partial result. + * transport, so missing or invalid response fields cause a search failure, not a partial result. */ function malformedRecord(detail: string, cause?: unknown): SearchError { return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 7b2c3ae596..a7b82bdd06 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -24,7 +24,7 @@ interface EditInput { } /** - * The `edit` tool's validated argument shape: the base parameters plus the two + * The `edit` tool's validated arguments: the base parameters plus the two * escalation fields, advertised only under a confining `ctx.fs` (absent from * the schema otherwise, so the validator rejects them before `execute`). */ diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 9a74dfc387..ba96e32cd1 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -43,7 +43,7 @@ ${verb} file } /** - * The `write` tool's validated argument shape: the base parameters plus the + * The `write` tool's validated arguments: the base parameters plus the * two escalation fields, advertised only under a confining `ctx.fs` (absent * from the schema otherwise, so the validator rejects them before `execute`). */ diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index 6360e61a10..6396c7f75b 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -72,7 +72,7 @@ function nonNegativeInteger(value: unknown, field: string): number { /** Decode one canonical blocker explanation. */ function decodeBlockReason(value: unknown): GoalBlockReason { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'code,message') { - throw new Error('goal change goal.blockedReason has an invalid shape') + throw new Error('goal change goal.blockedReason must have exactly code and message fields') } if (typeof value['code'] !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value['code'])) { throw new Error('goal change goal.blockedReason.code must be lower-kebab-case') @@ -102,7 +102,7 @@ function decodeSnapshot(value: unknown): GoalSnapshot { ? 'blockedReason,id,maxGoalRounds,objective,phase,revision' : 'id,maxGoalRounds,objective,phase,revision' if (Object.keys(value).sort().join(',') !== expectedKeys) { - throw new Error('goal change goal has an invalid shape') + throw new Error(`goal change goal for phase ${phase} must have exactly ${expectedKeys} fields`) } return { id: GoalId(value['id']), @@ -117,7 +117,7 @@ function decodeSnapshot(value: unknown): GoalSnapshot { /** Decode and validate one ref. */ function decodeRef(value: unknown): GoalRef { if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'id,revision') { - throw new Error('goal clear tombstone has an invalid shape') + throw new Error('goal clear tombstone must have exactly id and revision fields') } if (typeof value['id'] !== 'string' || value['id'].length === 0) { throw new Error('goal clear tombstone id must be a non-empty string') @@ -139,7 +139,7 @@ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { if (value['operation'] === 'clear') { const allowed = ['cleared', 'clearedAt', 'kind', 'operation', 'version'] if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { - throw new Error('goal clear change has an invalid shape') + throw new Error(`goal clear change must have exactly ${allowed.sort().join(',')} fields`) } return { kind: 'goal/change', @@ -155,7 +155,7 @@ export function decodeGoalChange(value: unknown): GoalChangeMeta | undefined { } const allowed = ['createdAt', 'goal', 'kind', 'operation', 'roundsStarted', 'updatedAt', 'version'] if (Object.keys(value).sort().join(',') !== allowed.sort().join(',')) { - throw new Error('goal snapshot change has an invalid shape') + throw new Error(`goal snapshot change must have exactly ${allowed.sort().join(',')} fields`) } const createdAt = nonNegativeInteger(value['createdAt'], 'createdAt') const updatedAt = nonNegativeInteger(value['updatedAt'], 'updatedAt') diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 3c642d2a8c..0c0a8e8373 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -502,8 +502,8 @@ describe('GoalService mutations', () => { session.append('goal/change', change) session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) - expect(() => ctx.goals.get(agent)).toThrow('invalid shape') - expect(() => ctx.goals.get(agent)).toThrow('invalid shape') + expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') + expect(() => ctx.goals.get(agent)).toThrow('snapshot change must have exactly') }) }) @@ -609,13 +609,13 @@ describe('goal replay validation', () => { expect(() => foldGoal(session.events)).toThrow('not the next admitted round') }) - it('rejects unsupported versions, operations, and top-level shapes', () => { + it('rejects unsupported versions, operations, and extra top-level fields', () => { expect(() => decodeGoalChange({ ...snapshotChange(), version: 2 })).toThrow('unsupported goal change version') expect(() => decodeGoalChange({ ...snapshotChange(), operation: 'explode' })).toThrow('operation is invalid') - expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change has an invalid shape') + expect(() => decodeGoalChange({ ...snapshotChange(), extra: true })).toThrow('snapshot change must have exactly') expect(() => decodeGoalChange({ kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'x', revision: 2 }, clearedAt: 1, extra: true, - })).toThrow('clear change has an invalid shape') + })).toThrow('clear change must have exactly') }) it('rejects invalid create and missing-current mutation sequences', () => { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4fe2b7806e..c30ee60ec2 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: d5afd21033afd8291c8059fb225deee3965f8b65 -README.zh.md: cde0b4fd286579f5b389bc601750ca8303b35f15 +README.md: 64f6ae7bcd92735f821c8f8d2b3b93203dbac17e +README.zh.md: 680fcee730674a21b5c2407247ce46b1a01cf6f3 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d5afd21033..64f6ae7bcd 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. +The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. ## The shared Agent default (`agent-default-model` Settings section) @@ -30,9 +30,9 @@ Question responses are validated against their pending request before the first Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. -`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. +`session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. -Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. +Session model selection is a session-domain contract. `session.models` returns the current `ModelSelection` separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt assembly. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. `session.prompt` refuses on the same fact with `model-unavailable` before opening a turn; a disabled composer is a client affordance, and the method remains callable. Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. @@ -68,7 +68,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Pending-interaction state is host-side** — the wire shape is POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. +- **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cde0b4fd28..680fcee730 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 +所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 ## 共享 Agent 默认值(`agent-default-model` Settings 分节) @@ -30,9 +30,9 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 -`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 +`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 -会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定将在下一提示词组装边界使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 +会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理(reasoning)元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。 待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 @@ -60,7 +60,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 模型体验 -无。该包定义客户端与宿主间的协议约定和载体,其中没有任何内容会进入模型请求。 +无。该包定义客户端与宿主间的 wire 约定和载体,其中没有任何内容会进入模型请求。 #### KV Cache 影响 @@ -68,7 +68,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与暂缓事项 -- **待处理交互状态位于宿主侧**:协议形状为 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 +- **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 - **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 diff --git a/packages/host/apiproxy/src/api/approvals.schema.ts b/packages/host/apiproxy/src/api/approvals.schema.ts index 6c6e90fb17..2790d98a97 100644 --- a/packages/host/apiproxy/src/api/approvals.schema.ts +++ b/packages/host/apiproxy/src/api/approvals.schema.ts @@ -10,7 +10,7 @@ import type { ApprovalResponsePayload } from './approvals.ts' import type { Wire } from './rpc.schema.ts' import { sessionIdSchema } from './sessions.schema.ts' -/** ApprovalRequestId: one brand cast after shape validation (the only cast point in this domain). */ +/** ApprovalRequestId: one brand cast after schema validation (the only cast point in this domain). */ export const approvalRequestIdSchema = z.string().min(1) as unknown as z.ZodType<ApprovalRequestId> /** Approval answer payload (the result.value slot of a client-response). */ diff --git a/packages/host/apiproxy/src/api/commands.schema.ts b/packages/host/apiproxy/src/api/commands.schema.ts index 89bfa76aa2..c135c82e5a 100644 --- a/packages/host/apiproxy/src/api/commands.schema.ts +++ b/packages/host/apiproxy/src/api/commands.schema.ts @@ -33,7 +33,7 @@ export const commandExecuteRequestSchema = z.object({ line: z.string(), }) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>> -/** CommandId: one brand cast after shape validation (the only cast point in this domain). */ +/** CommandId: one brand cast after schema validation (the only cast point in this domain). */ export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId> /** command.execute response value: pure admission — outcomes ride the logged diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index fc841f9edb..c06efb9057 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -15,7 +15,7 @@ import { } from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' -/** Question shape validated strictly against core dsh-user-interaction. */ +/** Question fields validated strictly against core dsh-user-interaction. */ export const askUserQuestionItemSchema = z.object({ id: z.string(), question: z.string(), diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 145b0500cf..f591283aec 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -23,8 +23,8 @@ export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[] : T /** - * RpcId: one brand cast after shape validation (the only cast point in this - * file). No min-length: the id is an opaque echo token, and rejecting shapes + * RpcId: one brand cast after schema validation (the only cast point in this + * file). No min-length: the id is an opaque echo token, and rejecting values * here would only turn a correlatable error report into a client-side parse * failure (the handler substitutes a sentinel when a request's id is unreadable). */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index e0ce444acf..81e150bc20 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -23,7 +23,7 @@ import { truncateUnicodeCodePoints, } from './session-search.ts' -/** SessionId: one brand cast after shape validation (the only cast point in this domain). */ +/** SessionId: one brand cast after schema validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId> /** MessageId: one brand cast after non-empty string validation. */ diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml index 49b198d446..22a7a47e27 100644 --- a/packages/host/directory-picker-auto/README.i18n.yaml +++ b/packages/host/directory-picker-auto/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-auto/README.md -README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6 -README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944 +README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b +README.zh.md: dc67456e9b86636522406bf6a57929b24793dade diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md index f1715566c8..b1bbe4f97c 100644 --- a/packages/host/directory-picker-auto/README.md +++ b/packages/host/directory-picker-auto/README.md @@ -16,6 +16,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments. +- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments. - **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot. - **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once. diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md index 9fc8e539d4..dc67456e9b 100644 --- a/packages/host/directory-picker-auto/README.zh.md +++ b/packages/host/directory-picker-auto/README.zh.md @@ -16,6 +16,6 @@ ## 已知限制与暂缓事项 -- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。 +- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。 - **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。 - **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。 diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index aeb36e44d7..5ba3a2bed1 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md -README.md: 3749b238b56578ec68610bc13550760aa084bad6 -README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735 +README.md: d90f939aca57b6bc520bb96b56b8a7738b69a522 +README.zh.md: 40d82b3d60ab7d27100133385a73f31d8cb3c26a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 3749b238b5..d90f939aca 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself. +The web GUI host's workspace-directory picker is a capability seam. The abstract `DirectoryPicker` service (`ctx.directoryPicker`) is its Service Definition. Its only method, `capability()`, returns a discriminated union describing how an operator selects a directory. Backends differ in user interaction, not just implementation: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` provides listing and creation operations for an in-app browser, which works for remote clients that cannot reach an OS chooser ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map, and a new backend adds its variant there through declaration merging. For an unknown kind, consumers hide directory picking rather than fail. The capability object must be stable for the service lifetime. Each backend package also has a browser entrypoint that registers the matching interaction in ui-workspace's directory-flow slots, so one composition row selects both the host capability and the client flow. A composition that should choose at runtime mounts [`-auto`](../directory-picker-auto/README.md), which inspects the host once at boot and mounts the matching backend row. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). @@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note. +- **No multi-root support** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the DirectoryPicker Agent Note. diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index bc77a9c6e1..40d82b3d60 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一约定方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。 +web GUI 宿主的工作区目录选择是一项能力 seam。抽象的 `DirectoryPicker` 服务(`ctx.directoryPicker`)是其 Service Definition。该服务只提供一个方法:`capability()`,它返回一个可辨识联合类型,说明操作者如何选择目录。后端之间的用户交互不同,不只是实现不同:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器使用的列举与创建操作,也能服务于无法访问 OS 对话框的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生,新后端通过声明合并在其中加入自己的变体。遇到未知 kind 时,消费方会隐藏目录选择入口,而不是失败。能力对象在服务生命周期内必须保持稳定。每个后端包还提供 browser 入口,在 ui-workspace 的 directory-flow slot 中注册匹配的交互,因此一项组合配置会同时选择宿主能力与 client 流程。需要在运行时选择交互的组合挂载 [`-auto`](../directory-picker-auto/README.md),它在启动时检查一次宿主情况,并挂载匹配的后端行。 浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 @@ -16,4 +16,4 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker` ## 已知限制与暂缓事项 -- **约定未定义多根目录词汇**——浏览约定每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 +- **不支持多根目录**——浏览约定每次列举只公开一条祖先链;按部署限定可浏览根(以及在盘符根的上一级枚举 Windows 各盘符根目录)等到出现需要它的消费方再做,见 DirectoryPicker Agent Note。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index a9e5d9e48a..ecefc4db11 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/webserver/README.md -README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd -README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508 +README.md: c41001fba3a69bfd7c00550d0be602e3fc2e0474 +README.zh.md: 061bed977e456ba6c3cd38f5ad3d30fe0c9354ab diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 569c3f0c19..c41001fba3 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. +Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics. -The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. +The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell. A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 3aee064877..061bed977e 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。 +Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换;fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。 -该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 +该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认值)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。 监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。 diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index fbd275eeed..2ff04379e3 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -50,12 +50,11 @@ export interface Config { } /** - * The web-shape HTTP carrier service. Activation listens immediately (route - * registration order carries no request-facing semantics: named routes are - * composed to be disjoint, and the fallback seat answers anything not yet - * claimed during the boot window — 404 until its owner registers). A listen - * failure throws out of init — a FAILED fiber the boot's fail-loud sweep - * reports. + * The browser HTTP carrier service. Activation listens immediately. Route + * registration order does not affect requests because configured named routes + * must be distinct, and the fallback handler answers anything not yet claimed + * during startup with 404 until its owner registers. A listen failure rejects + * initialization, and the boot process reports the failed fiber. */ export class HttpServerService extends Service { static Config: z<Config> = z.object({ @@ -224,8 +223,8 @@ export class HttpServerService extends Service { }) }) - // Node does not include upgraded sockets in closeAllConnections(), so the - // service tracks and destroys them as part of the same ownership boundary. + // Node does not include upgraded sockets in closeAllConnections(). The service + // owns them with the other connections, so it tracks and destroys them explicitly. this.ctx.effect(() => async () => { const serverClosed = new Promise<void>((resolve) => { this.server.close(() => { resolve() }) diff --git a/packages/interaction/permission/src/invariant.ts b/packages/interaction/permission/src/invariant.ts index b1290b7307..3bd102645f 100644 --- a/packages/interaction/permission/src/invariant.ts +++ b/packages/interaction/permission/src/invariant.ts @@ -11,7 +11,7 @@ export const name = 'permission-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) { fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`) diff --git a/packages/interaction/user-interaction/README.i18n.yaml b/packages/interaction/user-interaction/README.i18n.yaml index 4537cfd7da..55b9514b60 100644 --- a/packages/interaction/user-interaction/README.i18n.yaml +++ b/packages/interaction/user-interaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/user-interaction/README.md -README.md: cf000dd59754dfe2f14395c33384bad5bda76910 -README.zh.md: 67167649e29afe99667ab6c863127d27d4bceb48 +README.md: a1fe8e63011b0726e67f8b873b0b67f4af2e890a +README.zh.md: a6a0750bd91a316ebfeaef7859d5079f7ee8b616 diff --git a/packages/interaction/user-interaction/README.md b/packages/interaction/user-interaction/README.md index cf000dd597..a1fe8e6301 100644 --- a/packages/interaction/user-interaction/README.md +++ b/packages/interaction/user-interaction/README.md @@ -26,7 +26,7 @@ When a request carries an agent, `ask()` authenticates its exact identity throug ### Presentation intent -`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. +`intent` declares that a question IS a known kind of decision, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read the same answer fields either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. ## Role diff --git a/packages/interaction/user-interaction/README.zh.md b/packages/interaction/user-interaction/README.zh.md index 67167649e2..a6a0750bd9 100644 --- a/packages/interaction/user-interaction/README.zh.md +++ b/packages/interaction/user-interaction/README.zh.md @@ -26,7 +26,7 @@ ### 呈现意图 -`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 +`intent` 声明某个问题本身就是一种已知决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只改变呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的回答字段相同。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 ## 职责 diff --git a/packages/interaction/user-interaction/src/types.ts b/packages/interaction/user-interaction/src/types.ts index 51edfc6bf7..81be220592 100644 --- a/packages/interaction/user-interaction/src/types.ts +++ b/packages/interaction/user-interaction/src/types.ts @@ -1,5 +1,5 @@ /** - * Wire-safe question/answer shapes, free of cordis/service imports so browser + * Wire-safe question and answer types, free of cordis/service imports so browser * type chains (apiproxy api → client) can consume them without loading this * package's Context augmentation. * @module @deepseek-ai/dsh-user-interaction/types @@ -14,11 +14,11 @@ export interface AskUserQuestionOption { } /** - * A caller-declared presentation intent: the question IS a decision of this - * shape, so a UI that recognises the tag may present it as such instead of as a + * A caller-declared presentation intent: the question IS this kind of + * decision, so a UI that recognises the tag may present it as such instead of as a * generic option list. Tagged so further intents can be added; a UI that does * not know a tag renders the generic flow, and the answer encoding is identical - * either way — an intent shapes presentation only, never the protocol. + * either way — an intent changes presentation only, never the protocol. */ export type AskUserQuestionIntent = { /** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */ diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 840a8c2865..4011ff5ba9 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: 7151fdf5b63f48e625d00a92dc42aa24b7de2f31 -README.zh.md: 0bfd5c706e01dd4448edb9cf0eec812831f68093 +README.md: f6a1eefe6083d801009a5b788a07b58d6e696a5a +README.zh.md: f4c5ddd6dbe05ae709145cfac341f17a716bac82 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 7151fdf5b6..f6a1eefe60 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -173,7 +173,7 @@ Conversion preserves logical request order without adding text, while the select #### What the model sees -pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings. +pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. The adapter passes parsed tool arguments to the harness as raw JSON strings. #### Token effect diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0bfd5c706e..f4c5ddd6db 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -173,7 +173,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。已解析工具参数以原始 JSON 字符串形式通过 harness 边界传递。 +pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。适配器把解析后的工具参数作为原始 JSON 字符串传给 harness。 #### Token 影响 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 4e0cf3c092..90cf145975 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -145,12 +145,12 @@ export type PiAiReasoningEfforts = Partial<Record<ModelThinkingLevel, string | n * default) or per model (winning over the route). Only the switches pi-ai's * reasoning dispatch reads are offered; the rest of pi-ai's compat surface * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning - * shape in the protocol itself — so resolution rejects a model-level switch + * `OpenAICompletionsCompat` — the other wire protocols define their reasoning + * fields in the protocol itself — so resolution rejects a model-level switch * anywhere else, while a route-level default skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ thinkingFormat?: PiAiThinkingFormat /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ supportsReasoningEffort?: boolean diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index e52af4a3f4..a2074302e8 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -163,12 +163,12 @@ const compatProfile: z<PiAiCompatProfile> = z.object({ /** * Keys are the offered levels, values their wire spellings. A valueless key * (`off:`) survives validation because schemastery passes nullable data - * through before any member schema runs — `z.const(null)` only shapes the - * error for non-null wrong values and what a configuration surface renders. + * through before any member schema runs — `z.const(null)` only controls the + * error for non-null wrong values and what a configuration UI renders. * Only resolution decides which levels may leave the value empty, so the * diagnostic can name the route and model. The assertion narrows * schemastery's `Dict`, which types every literal key as required; dict - * validation is per-present-key, so the runtime shape is the partial record. + * validation checks only present keys, so the runtime value is a partial record. */ const reasoningEfforts = z.dict( z.union([z.string(), z.const(null)]), @@ -237,7 +237,7 @@ export function assertServiceable(config: Config): void { resolveProfiles(config.providers) } -/** Reject a pre-release profile shape, naming the replacement. */ +/** Reject removed pre-release profile fields and name their replacements. */ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void { const legacy = source as PiAiProviderProfile & { provider?: unknown diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8a7dec1265..75d8d6f364 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -184,8 +184,8 @@ export interface PreparedLlmCall { /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include - * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch - * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals. + * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch + * DeepSeek and library-backed pi-ai adapters meet this contract through different internals. */ export abstract class LlmAdapter { /** diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts index 2673072fa0..7863e66d58 100644 --- a/packages/llm/llm/src/message.ts +++ b/packages/llm/llm/src/message.ts @@ -30,8 +30,8 @@ export interface ToolMessageSource { } /** - * What SHAPE of information a producer-supplied context carries, declared by - * the producer beside the source fields it supplied. + * The kind of information in producer-supplied context, declared by the + * producer beside its provenance. * * `MessageSource.kind` answers *who produced this*; `form` answers *what kind * of thing it is*, and the two axes are deliberately independent — several @@ -69,10 +69,10 @@ export interface ContextSnapshotSection { /** * Producer-declared {@link ContextForm} and the fields that form requires, - * mixed into the source shapes that carry one. + * mixed into the source types that carry one. * - * Discriminated by `form` so a producer cannot declare a shape without the - * facts that shape is presented from: a `notice` must record its one-line + * Discriminated by `form` so a producer cannot select a form without the + * fields needed to present it: a `notice` must record its one-line * account, a `snapshot` its sections. Omitting `form` stays valid — an * undeclared context is the documented default. */ diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index ad7e8f66ba..70528bf53a 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -1,6 +1,6 @@ /** * Canonical provider-neutral message and streaming vocabulary for the loop, - * session log, and plugins. Adapters alone translate provider wire shapes; + * session log, and plugins. Adapters alone translate provider wire messages; * mapped interfaces make the content, source, and finish unions extensible. */ @@ -21,13 +21,13 @@ export type { UserMessage, } from './message.ts' -/** Serializable provider-boundary facts; policy decides whether they are retryable. */ +/** Serializable provider or transport failure facts; policy decides whether they are retryable. */ export interface LlmFailure { /** Human-readable provider or transport failure. */ readonly message: string /** Stable provider-neutral machine-routing code. */ readonly code: string - /** HTTP status observed at the provider boundary, when available. */ + /** HTTP status returned by the provider, when available. */ readonly status?: number /** Provider-requested delay in milliseconds, when valid and available. */ readonly providerRetryAfterMs?: number @@ -89,7 +89,7 @@ export interface ContentBlockMap { 'tool-result': ToolResultBlock } -/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */ +/** The block `type` tag vocabulary; widens as plugins add entries to {@link ContentBlockMap}. */ export type ContentBlockType = keyof ContentBlockMap /** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */ export type ContentBlock = ContentBlockMap[ContentBlockType] diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index d3b024a2ee..a5ac463fb2 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -206,7 +206,7 @@ export class TokenMeterService extends Service { if (state.stepStart === undefined || state.stepStart.turn !== event.data.turn || state.stepStart.step !== event.data.step) { - throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`) + throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start event`) } nextStepStart = undefined break @@ -223,7 +223,7 @@ export class TokenMeterService extends Service { if (stepStart === undefined || stepStart.turn !== event.data.turn || stepStart.step !== event.data.step) { - throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`) + throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start event`) } // assistant/message is surface-mandatory at every append/seed boundary. diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 2a9323474e..3e0f9559e9 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: c404cfa73024804bc9f166cfb84fa5f87f723459 -README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410 +README.md: d7e19cc473695455df667cfd717703c2c303aafa +README.zh.md: e89b75df184d2283452ab069a2d559650f15bfef diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index c404cfa730..d7e19cc473 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes. +Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy enforce restrictions independently and do not read or write plan state. ## Durable state `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). +`ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). ## Model and human surfaces @@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Configuration @@ -91,8 +91,8 @@ Mode transitions do not change the tool catalog; plan arguments and review resul ## Known Limitations and Deferred Work -- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls. -- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it. +- Plan mode guides rather than enforces; deployments that need enforced restrictions must configure sandbox and approval controls independently. +- A selection made after the turn's final accepted pre-step is lost if the process exits before another accepted in-turn pre-step, so the UI must reapply it. - Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option. - A live child owned by another agent cannot open the `exit_plan_mode` review. The failed call tells the child to include the unresolved decision in its final result; durable fork lineage alone does not prevent a session resumed as a runtime root from opening the review. - Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow. diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 275a876698..e89b75df18 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行维度。 +按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略各自强制执行限制,且不读写 plan 状态。 ## 持久状态 `plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 -`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择,并等待下一个被接受的轮内 pre-step;返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 +`ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个 prompt 之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条追加路径皆然)。 ## 模型与人类交互 @@ -16,13 +16,13 @@ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。 -组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。 +组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。 Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 ## 配置 @@ -91,8 +91,8 @@ mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩 ## 已知限制与暂缓事项 -- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。 -- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。 +- Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。 +- 如果进程在另一个被接受的轮内 pre-step 之前退出,某轮最后一个被接受的 pre-step 之后作出的选择会丢失,因此 UI 必须重新应用它。 - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。 - 由另一个 agent 所有的存活子级无法打开 `exit_plan_mode` 审阅。该调用失败时会提示子级在最终结果中包含尚未解决的决策;仅有持久化 fork 谱系并不会阻止恢复为运行时根的会话打开该审阅。 - 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 00234424ff..86da4d4935 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -1,20 +1,21 @@ /** * Plan mode is logged per-agent collaboration state: while active, a - * deployment-owned guidance section shapes each model request, and + * deployment-owned guidance section is included in each model request, and * `exit_plan_mode` presents the completed plan for user review, while the - * `/plan off` command lets a user leave directly. Plan mode is independent of - * sandbox mode and approval policy; those enforcement axes do not read or - * write plan state. + * `/plan off` command lets a user leave directly. Sandbox mode and approval + * policy enforce restrictions independently and do not read or write plan + * state. * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until an in-turn step boundary. The service - * projects pending intent into the proposed step assembly, then flushes it + * remain pending until the next accepted in-turn pre-step. The service includes + * the selected state in the proposed step assembly, then appends `plan/mode` * from `agent/pre-step` only when the step is accepted. Same-step request * retries reuse their assembly. * - * The exit tool remains registered while plan mode is inactive so crossing a - * boundary changes only the prompt section, not the request tool catalog. + * The exit tool remains registered while plan mode is inactive, so entering + * or leaving plan mode changes only the prompt section, not the request tool + * catalog. * * Agent Note: * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -97,7 +98,7 @@ function firstHeading(plan: string): string | undefined { /** * Validate deployment-owned plan guidance. Missing, blank, non-string, or - * unknown fields fail at plugin load rather than silently shaping nothing. + * unknown fields fail at plugin load rather than being ignored. * * @param config Raw plugin config. * @returns A detached validated config. @@ -176,7 +177,7 @@ function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefi } /** - * `ctx.planMode`: owns logged plan state, boundary application and narration, + * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. * UIs observe committed flips through `session/event`; there is no live mirror. */ @@ -187,7 +188,7 @@ export class PlanModeService extends Service { private readonly section: string /** - * Latest selection per session awaiting an in-turn request-boundary flush. + * Latest selection per session awaiting the next accepted in-turn pre-step. * `narrate` is true for user selections and false for the exit tool, whose * result already narrates the transition. */ @@ -197,10 +198,10 @@ export class PlanModeService extends Service { super(ctx, 'planMode') this.section = resolveConfig(config).section let disposed = false - // Pre-step is outside Session.append publication, so its log-only mode - // event can land between turns or inside an open turn without re-entering - // the session. A failed append remains pending for a later boundary, and - // policy cannot block the step. + // Pre-step is outside Session.append publication, so it can append the + // log-only mode event inside an open turn without re-entering the session. + // A failed append remains pending for a later accepted in-turn pre-step, + // and policy cannot block the step. ctx.on('agent/pre-step', async ( { agent, signal }, next, @@ -212,7 +213,7 @@ export class PlanModeService extends Service { try { this.onBoundary(agent.session) } catch (error) { - ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) + ctx.logger.warn('dsh-plan-mode: failed to append selected plan mode at step start: %o', error) return decision } return !pending.narrate || narration === undefined @@ -234,8 +235,9 @@ export class PlanModeService extends Service { // The plan projection unit (session-projection RFC): a pure double-event // fold serving clients the whole {active, pending} value. `command/run` // records the user's logged /plan selection (the handler calls `set()` - // before any failing path, so log and run-plane cannot fork); `plan/mode` - // is the boundary commit that resolves it. Pending is thereby a pure + // before any failing path, so a failed handler cannot leave the recorded + // command without its plan selection); `plan/mode` records that selection + // and clears it. Pending is thereby a pure // replay quantity: host restarts, other tabs, and cold reads all recover // it from the log alone. The unit child activates only when a projection // registry is composed (headless assemblies stay unaffected). @@ -280,8 +282,9 @@ export class PlanModeService extends Service { case 'cancelled': return { kind: 'success', text: 'Plan mode entry cancelled.' } case 'noop': - // Repeat the queued wording while an exit still awaits its - // boundary; only a truly inactive session reads idempotent. + // Repeat the queued wording while an exit still awaits the + // next accepted pre-step; only a truly inactive session reads + // idempotent. return foldPlanMode(agent.session.events) ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' } : { kind: 'success', text: 'Plan mode is already inactive.' } @@ -357,8 +360,8 @@ export class PlanModeService extends Service { } throw cause }) - // A review may outlive this plugin fiber. Without boundary listeners, - // an approved result could never land, so fail and keep planning. + // A review may outlive this plugin fiber. Without its pre-step listener, + // an approved selection could never be appended, so fail and keep planning. if (disposed) { throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again') } @@ -371,7 +374,8 @@ export class PlanModeService extends Service { : `The user chose to keep planning; their feedback: ${feedback}`) } // Keep plan guidance for the rest of this assistant tool batch. The - // silent intent flushes after the step, before the next assembly. + // silent selection is appended at the next accepted in-turn pre-step, + // before its request assembly. this.pendingIntents.set(agent.session, { active: false, narrate: false }) return { approved: true } }, @@ -390,7 +394,8 @@ export class PlanModeService extends Service { } /** - * Read the logged plan state and any selected state awaiting a boundary. + * Read the logged plan state and any selected state awaiting the next + * accepted in-turn pre-step. * * @param agent The agent to read. * @returns Current logged state plus a pending selection, when present. @@ -402,20 +407,20 @@ export class PlanModeService extends Service { } /** - * Select whether plan mode should be active. Between turns the change - * commits immediately — no request boundary would arrive until the next - * prompt, so a queued intent would hang (the open-turn fold is the idle - * signal: agent status stays `running` through post-turn checkpointing, - * where a boundary equally never comes). During an open turn the - * selection is held as pending intent for the next in-turn request - * boundary. Repeated selection of the current or already-pending state is - * a no-op. + * Select whether plan mode should be active. Between turns the method + * appends the change immediately because no in-turn pre-step will run until + * another prompt starts a turn. The open-turn fold is the idle signal: + * agent status stays `running` through post-turn checkpointing, when no + * further in-turn pre-step runs. During an open turn the selection remains + * pending until the next accepted in-turn pre-step. Repeated selection of + * the current or already-pending state is a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. * @returns what happened: `committed` (logged now), `queued` (awaiting the - * next boundary), `cancelled` (an opposite pending selection was cleared; - * the logged state already matches), or `noop` (already in that state). + * next accepted in-turn pre-step), `cancelled` (an opposite pending selection + * was cleared; the logged state already matches), or `noop` (already in that + * state). */ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' { const session = agent.session @@ -439,7 +444,7 @@ export class PlanModeService extends Service { return 'committed' } - /** Flush one pending selection before the next request assembly. */ + /** Append one pending selection before the next request assembly. */ private onBoundary(session: Session): void { const pending = this.pendingIntents.get(session) if (pending === undefined) return @@ -449,8 +454,8 @@ export class PlanModeService extends Service { return } session.append('plan/mode', { active: target }) - // Delete only after append succeeds so a later boundary can retry a failed - // durable write. + // Delete only after append succeeds so a later accepted in-turn pre-step + // can retry a failed durable write. this.pendingIntents.delete(session) } diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index eafd5f0aff..a3c10d2252 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -11,8 +11,8 @@ /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while - * a logged `/plan` selection (`command/run`) awaits its request-boundary - * `plan/mode` commit and targets a state other than `active`. Capability + * a logged `/plan` selection (`command/run`) targets a state other than + * `active` and no later `plan/mode` event has recorded that state. Capability * absence (plan-mode not composed) is the key's absence, never a value. */ export interface PlanProjection { diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index c504ff5df6..b1e546d8b7 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -69,7 +69,7 @@ describe('plan projection unit', () => { expect(bench.values()).toEqual({ plan: { active: false, pending: false } }) }) - it('a logged /plan selection reads pending until the boundary commit resolves it', async () => { + it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 13fa52ce9d..ba2e21b8e9 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 23d3a32451c105c71c0a7399ed051288b70753f3 -README.zh.md: 1890771faf8cab6b1842f973a999c7a9cf2dbb11 +README.md: 4d9e8275ba3fe0c1f49555b61e319f52194244bc +README.zh.md: 8a755e6c5b0c266538277bbbcd118fd24ab164f3 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 23d3a32451..4d9e8275ba 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -35,4 +35,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. -- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. +- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-compatible profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 1890771faf..8a755e6c5b 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -35,4 +35,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 -- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 +- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现与 bwrap 兼容的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 42a150b855..fc19a8dbea 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -42,7 +42,7 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './pr /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the runner argv; bwrap-shaped profile arguments are appended. A + * Override the runner argv; bwrap-compatible profile arguments are appended. A * non-empty override asserts full enforcement and skips built-in selection and * probing. A runner that starts but refuses its profile must be identifiable by * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 90b8bf65fd..20fd176af6 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -13,7 +13,7 @@ export const name = 'sandbox-policy-invariant' export const inject = ['invariants'] /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) { fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`) diff --git a/packages/scaffold/client/src/api.ts b/packages/scaffold/client/src/api.ts index 6e76efa417..d615caece5 100644 --- a/packages/scaffold/client/src/api.ts +++ b/packages/scaffold/client/src/api.ts @@ -203,7 +203,7 @@ export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] { return typeof input === 'string' ? [{ type: 'text', text: input }] : input } -/** Validate a wire `session.event` envelope to the shape the typed result exposes. */ +/** Validate the fields in a wire `session.event` envelope before returning the typed result. */ function validatedSessionEvent(value: unknown): SessionEvent { if (!isRecord(value) || typeof value.type !== 'string') { throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`) diff --git a/packages/scaffold/helper/src/documents/tsconfig-file.ts b/packages/scaffold/helper/src/documents/tsconfig-file.ts index 368b93eb43..2f61e10951 100644 --- a/packages/scaffold/helper/src/documents/tsconfig-file.ts +++ b/packages/scaffold/helper/src/documents/tsconfig-file.ts @@ -70,7 +70,7 @@ export class TsConfigFile extends ProjectFile { )) } - /** Validate JSONC and the project-reference shape. */ + /** Validate JSONC and the project-reference fields. */ override validate(): void { const value = parseConfig(this.text) if (value.references === undefined) return diff --git a/packages/scaffold/helper/src/features/define-feature.ts b/packages/scaffold/helper/src/features/define-feature.ts index 84b020591c..720f15ecd6 100644 --- a/packages/scaffold/helper/src/features/define-feature.ts +++ b/packages/scaffold/helper/src/features/define-feature.ts @@ -114,7 +114,7 @@ function configDiagnostics( if (!expected || Object.keys(expected).length === 0) return undefined return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key]) ? [] - : [`${key} has an incompatible value shape`]) + : [`${key} has fields or value types that do not match the expected config`]) } function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] { diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts index 93d51c5ffe..6fec1e84e3 100644 --- a/packages/scaffold/helper/src/features/feature.ts +++ b/packages/scaffold/helper/src/features/feature.ts @@ -236,7 +236,7 @@ export abstract class Feature { } /** - * Inspect current files and reject any partial or ambiguous owned shape. + * Inspect current files and reject any partial or ambiguous owned file set. * @param project - project snapshot to inspect. * @returns installation state, selection, and diagnostics. */ diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts index 1251fe45e2..46a020f40a 100644 --- a/packages/self-modification/repository-plugin/src/index.ts +++ b/packages/self-modification/repository-plugin/src/index.ts @@ -92,7 +92,7 @@ async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise process.env, directory, // Schemastery call signatures collapse the parameter to `never` under - // NodeNext; ResolvedMcpServer is shaped for the Config union by design. + // NodeNext; ResolvedMcpServer matches the Config union by design. ).map(input => McpClient.Config(input as never)) await ctx.effect(async function* () { diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index fdb852533d..929bddece9 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -486,7 +486,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'httpServer', - summary: 'The web-shape HTTP carrier service.', + summary: 'The browser HTTP carrier service.', methods: [ { signature: 'register(route: WebRoute): () => void', @@ -602,15 +602,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'planMode', - summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.', + summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', - jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */', + jsDoc: '/**\n * Read the logged plan state and any selected state awaiting the next\n * accepted in-turn pre-step.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */', }, { signature: 'set(agent: Agent, active: boolean): \'committed\' | \'queued\' | \'cancelled\' | \'noop\'', - jsDoc: '/**\n * Select whether plan mode should be active. Between turns the change\n * commits immediately — no request boundary would arrive until the next\n * prompt, so a queued intent would hang (the open-turn fold is the idle\n * signal: agent status stays `running` through post-turn checkpointing,\n * where a boundary equally never comes). During an open turn the\n * selection is held as pending intent for the next in-turn request\n * boundary. Repeated selection of the current or already-pending state is\n * a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next boundary), `cancelled` (an opposite pending selection was cleared;\n * the logged state already matches), or `noop` (already in that state).\n */', + jsDoc: '/**\n * Select whether plan mode should be active. Between turns the method\n * appends the change immediately because no in-turn pre-step will run until\n * another prompt starts a turn. The open-turn fold is the idle signal:\n * agent status stays `running` through post-turn checkpointing, when no\n * further in-turn pre-step runs. During an open turn the selection remains\n * pending until the next accepted in-turn pre-step. Repeated selection of\n * the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next accepted in-turn pre-step), `cancelled` (an opposite pending selection\n * was cleared; the logged state already matches), or `noop` (already in that\n * state).\n */', }, ], }, @@ -746,7 +746,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void', - jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, boundary schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */', + jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, state schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', @@ -820,11 +820,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot>', - jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */', + jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and the last sequence number included in the raw-log capture.\n * @throws when source resolution fails or the session surface is invalid.\n */', }, { signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace>', - jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', + jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or the first parent that could not be resolved.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation>', @@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'telemetry', - summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', + summary: 'Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', methods: [ { signature: 'abstract emit(record: TelemetryRecord): void', @@ -1308,7 +1308,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>', - jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query plus result-shaping options.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', + jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query and optional result limit.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', }, { signature: 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>', @@ -1630,8 +1630,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/code-dispatch-log', mode: 'waterfall', signature: '\'tools/code-dispatch-log\'(this: Scoped<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>', - jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', - summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + jsDoc: '/**\n * Allow a listener to replace content in the DURABLE LOG COPY of one\n * `run_code` sub-dispatch outcome before the bridge appends its\n * `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the original settled content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', + summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', }, { name: 'tools/execute', diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts index 6b3e20a82c..f093da3e24 100644 --- a/packages/self-modification/tool-cordis/src/sandbox.ts +++ b/packages/self-modification/tool-cordis/src/sandbox.ts @@ -56,8 +56,8 @@ const TIMER_REDIRECT /** * The callable Node APIs the sandbox deliberately disables, each mapped to the - * cordis alternative its trap error names. Only FUNCTION-shaped globals are - * trapped — a data-shaped global like `process` stays `undefined`, because a + * cordis alternative its trap error names. Only function-valued globals are + * trapped; a data-valued global such as `process` stays `undefined`, because a * throwing accessor would detonate the common `typeof process` feature probe * at resolution time. */ diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index b891f5750d..919bf00c88 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -257,7 +257,7 @@ export abstract class SessionQueryService extends Service { /** * Read one session's complete current model surface from one corpus observation. * @param sessionId - live-preferred session id to read. - * @returns cloned header, current surface, and raw-log capture boundary. + * @returns cloned header, current surface, and the last sequence number included in the raw-log capture. * @throws when source resolution fails or the session surface is invalid. */ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> { @@ -273,7 +273,7 @@ export abstract class SessionQueryService extends Service { * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @param signal - optional cancellation for persistence listing. - * @returns a complete lineage or an explicit unresolved parent boundary. + * @returns a complete lineage or the first parent that could not be resolved. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> { diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index fd62306b1a..809982f94d 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -211,8 +211,8 @@ export function logPath( * `packChunks` on, delta-chunk runs pack into `text-chunks` / * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event * per line, byte-identical to the pre-packing layout. Reading is layout-blind - * either way ({@link scanLog} always decodes rows), so the switch only shapes - * NEW bytes. + * either way ({@link scanLog} always decodes rows), so the switch changes only + * newly written bytes. * @param events - the batch to serialize, in log order. * @param packChunks - whether to pack delta runs into storage rows. * @returns the batch's JSONL text; the writer adds the final newline. diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 74a0d4c8a9..15808bb5e4 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: c64826db1e9c7339f43a64ad7c13f01a2a39638e -README.zh.md: 651d920404300fa602f77cee63b0f31aec837919 +README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 +README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index c64826db1e..391548b1b8 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The durable session-persistence Service Definition (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a Service provider in a sibling package, and Consumers that inject the service. +Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-bash` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service provider, and Consumers inject the service. The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here. @@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -35,7 +35,7 @@ Each `session/event` copies its event into the session controller. The first pen Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. +Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 651d920404..7213e1ee71 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这是用于持久保存会话的 Service Definition(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 能力 seam 模板一致(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供 Service provider,Consumer 注入服务。 +会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-bash` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition,同级包负责 Service provider,Consumer 注入该服务。 持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。 @@ -14,9 +14,9 @@ | `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 | +| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -35,7 +35,7 @@ 崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构旧记录中未命名调用方的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 1d0364d1f2..154a5ca2fa 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -65,7 +65,7 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> { */ view(state: S): SessionProjectionMap[K] /** - * Persisted-cache invalidation anchor: bump whenever the state shape or the + * Persisted-cache invalidation version: bump whenever the serialized state fields or the * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` * rows from an older unit are discarded instead of being forward-applied * into garbage. Non-negative integer. @@ -188,7 +188,7 @@ export class SessionProjectionRegistry extends Service { * context's fiber: disposing the fiber (or calling the returned disposer) * removes the key — and the unit's cached cells — from subsequent drives * and snapshots. - * @param definition - key, boundary schema, pure unit functions, and stateVersion. + * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void { diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b66d641750..50776f7d3f 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -3,9 +3,8 @@ * * Composes the OTel JS SDK as-is — a `LoggerProvider` with a * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each - * record handed over by the capture coordinator onto `logger.emit()`. Per the Service Definition's - * boundary axiom, everything downstream of that call (batching, retry, - * queueing, loss policy) is the SDK's documented behavior, configured + * record handed over by the capture coordinator onto `logger.emit()`. After that call, + * batching, retry, queueing, and loss policy use the SDK's documented behavior, configured * verbatim through the `exporter`/`processor` passthroughs. This package owns * capture mode and an outer shutdown deadline: the SDK's export timeout does * not bound its preceding `forceFlush()` wait. @@ -73,7 +72,7 @@ function assertNever(value: never): never { } /** - * Plugin configuration: one sharing policy, two verbatim SDK option shapes, + * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint * and shutdown deadline at plugin load; `DISABLED` reads neither. */ @@ -101,11 +100,10 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin - * starts. Shape-level only — load-bearing value checks live in the constructor - * so their errors name the fields. Both SDK slots are opaque passthroughs: - * the SDK owns their shapes and validates its own options; - * re-declaring them field-by-field here would violate the boundary axiom - * (and silently drop every field not re-declared). + * starts. It checks only the top-level fields; value checks live in the constructor + * so their errors name the fields. Both SDK option objects pass through unchanged: + * the SDK defines and validates their fields. Re-declaring them here would + * silently drop every field this plugin did not repeat. */ export const Config: z<Config> = z.object({ mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE), diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index c6c4b9eff9..3d4650361f 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md -README.md: 506f0f5bcb03a54f09805ed0f866a396e3cf0334 -README.zh.md: b5e47c8832452ece281dcdcfac007d6082813583 +README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 +README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 506f0f5bcb..827554dd53 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The telemetry Service Definition and capture coordinator sit behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). +The telemetry Service Definition declares the `TelemetryBackend` contract, and its capture coordinator passes session records to any reporting SDK backend that implements it. Capture can follow live session events or replay a canonical session-log prefix on demand. This package stops after it calls `emit()`: batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md). ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger. +`TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. ## Capture points diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index b5e47c8832..a350ea5935 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -遥测(telemetry)Service Definition 与捕获协调器位于一个后端约定之后,任何上报 SDK 都无需变形即可满足该约定。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 +遥测(telemetry)Service Definition 声明 `TelemetryBackend` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。 ## 后端约定 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。 +`TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 ## 捕获点 diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 977527aace..7ddd85fe8e 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -87,9 +87,8 @@ export interface TelemetryRecord { } /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ export interface TelemetryBackend { @@ -104,8 +103,8 @@ export interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -132,7 +131,7 @@ export interface TelemetryBackend { } /** - * The backend contract in its loadable form: one implementation per context — + * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a * duplicate, cordis' standard behavior. A backend composes a * {@link TelemetryCoordinator} in its constructor to install the capture side. diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index 4452e78dab..fba3913f75 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/settings/settings/README.md -README.md: 5bfbf4623c937c2b66886f71adf27075523f5d28 -README.zh.md: 98808424ba1af2210067bd7f74baa6027decbb06 +README.md: 7917f38017bfb23dc4718ee533c1f9a92b519d41 +README.zh.md: f46cf433b4d2207b17b0f40cc3b9cf70794b512c diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 5bfbf4623c..7917f38017 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -11,7 +11,7 @@ User-settings Service Definition (`ctx.settings`). One provider holds a raw docu - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. - `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires. - `get(ns)` — resolved value, `undefined` while unregistered. -- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. +- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches may contain only JSON-compatible data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently change such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. - `replace(ns, section)` — sets the user section wholesale: the deliberate reset (`replace({})` re-inherits `base` and schema defaults). - `mutate(ns, ops)` — applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale deletes every secret the wire never returned, while an op names the one field it means. - Every write takes an optional `expectedRevision`. Each descriptor carries the namespace's `revision`, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects with `SettingsConflictError` (`code: 'SETTINGS_CONFLICT'`, both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index 98808424ba..f46cf433b4 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -11,7 +11,7 @@ - `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。 - `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 封装、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个协议接口都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。 - `get(ns)` — 解析值;未注册时为 `undefined`。 -- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 +- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 只能包含与 JSON 兼容的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默改变这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。 - `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。 - `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。 - 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先完成写入的写入方。写队列只保证写入的先后次序,它本身分辨不出新的写入方与持有陈旧快照的写入方。 diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index d0f85a2b1b..37d3ec1d50 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -120,14 +120,14 @@ export interface SettingsScope<T> { watch(callback: (next: T, prev: T) => void | Promise<void>): () => void /** * Merge a partial patch into this namespace's user layer and persist it. - * @param patch - plain-object patch over the user section; JSON-shaped data + * @param patch - plain-object patch over the user section; JSON-compatible data * only (non-JSON values reject with their path before anything persists). */ update(patch: object): Promise<void> /** * Replace this namespace's user section wholesale; absent keys re-inherit * the composition `base` and schema defaults (`replace({})` resets all). - * @param section - the complete next user section; JSON-shaped data only, + * @param section - the complete next user section; JSON-compatible data only, * as for {@link update}. */ replace(section: object): Promise<void> @@ -172,11 +172,11 @@ declare module 'cordis' { } /** - * Deep equality over JSON-shaped data (objects, arrays, primitives) — the + * Deep equality over JSON-compatible data (objects, arrays, primitives) — the * Service Definition's single change-detection predicate, exported so the invariant * companion checks exactly the implementation's relation. - * @param a - one JSON-shaped value. - * @param b - the other JSON-shaped value. + * @param a - one JSON-compatible value. + * @param b - the other JSON-compatible value. * @returns whether the two values are structurally equal. */ export function deepEqualJson(a: unknown, b: unknown): boolean { @@ -264,7 +264,7 @@ function applyPathOp(section: Record<string, unknown>, op: SettingsPathOp): Reco return { ...section, [head]: applyPathOp(child, { ...op, path: rest }) } } -/** Human label for a value rejected by the JSON-shape boundary (numbers reject inline). */ +/** Human label for a value that lossless JSON cannot represent (numbers reject inline). */ function describeRejected(value: unknown): string { if (value === undefined) return 'undefined' if (typeof value === 'object' && value !== null) { @@ -276,16 +276,16 @@ function describeRejected(value: unknown): string { } /** - * Detach one write input in a single walk that doubles as the durable-boundary - * shape check: only JSON data (plain objects, arrays, strings, finite numbers, + * Detach and validate one write input in a single walk before persistence: + * only JSON data (plain objects, arrays, strings, finite numbers, * booleans, `null`) may reach a provider document. `structuredClone` alone * would admit Dates, Maps, BigInts, and cycles that YAML/JSON storage then * silently distorts on the reload round-trip. `undefined` entries in objects * are skipped — the same sparse-patch semantics as {@link mergeLayers} — while * an `undefined` array entry is rejected rather than coerced. * @param root - plain-object write input (caller-checked). - * @param reject - builds the boundary error from a value label and its `$`-rooted path. - * @returns the detached JSON-shaped clone. + * @param reject - builds the validation error from a value label and its `$`-rooted path. + * @returns the detached JSON-compatible clone. */ function cloneJsonShaped( root: Record<string, unknown>, @@ -640,9 +640,9 @@ export abstract class Settings extends Service { } // Snapshot at call time: the queue must never read a caller-owned object // the caller may keep mutating while the write waits its turn. The same - // walk is the JSON-shape boundary check (see cloneJsonShaped). + // walk rejects values that JSON cannot preserve (see cloneJsonShaped). const snapshot = cloneJsonShaped(payload, (label, path) => - new TypeError(`settings ${verb} for "${ns}" must be JSON-shaped data (found ${label} at ${path})`)) + new TypeError(`settings ${verb} for "${ns}" must contain only JSON-compatible data (found ${label} at ${path})`)) const previous = this.writeQueues.get(ns) ?? Promise.resolve() // Chain past a failed predecessor: one rejected write must not poison the // namespace queue for every later caller. diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index 66c0f1fcf6..fb0efe1ff8 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -479,11 +479,11 @@ describe('second review regressions', () => { expect(applied).toEqual([1, 2]) }) - it('rejects a function value as not JSON-shaped', async () => { + it('rejects a function value as not JSON-compatible', async () => { const { ctx } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema) await expect(scope.update({ theme: () => 'dark' })) - .rejects.toThrow(/JSON-shaped.*function at \$\.theme/) + .rejects.toThrow(/JSON-compatible.*function at \$\.theme/) }) it('rejects a write still queued when the service disposes', async () => { @@ -616,7 +616,7 @@ describe('third review regressions', () => { const { ctx, provider } = await boot() const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() })) await expect(scope.update({ value: { at: new Date(0) } })) - .rejects.toThrow(/JSON-shaped.*Date at \$\.value\.at/) + .rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/) expect(provider.persisted).toEqual([]) }) @@ -914,10 +914,10 @@ describe('mutate (path-addressed writes)', () => { expect(ctx.settings.describe().find(d => d.ns === KEYED)!.user).toEqual({ apiKey: 'sk-stored' }) }) - it('rejects a value the JSON-shape boundary refuses', async () => { + it('rejects a value that lossless JSON cannot represent', async () => { const ctx = await mounted({ keyed: {} }) await expect(ctx.settings.mutate(KEYED, [{ op: 'set', path: ['baseURL'], value: new Date() }])) - .rejects.toThrow(/must be JSON-shaped data/) + .rejects.toThrow(/must contain only JSON-compatible data/) }) }) diff --git a/packages/storage/storage-domain/src/spec.ts b/packages/storage/storage-domain/src/spec.ts index 9e49ef41e3..bf5de09a29 100644 --- a/packages/storage/storage-domain/src/spec.ts +++ b/packages/storage/storage-domain/src/spec.ts @@ -65,7 +65,7 @@ export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTabl } /** - * Identity helper that pins a spec's literal types and validates its shape. + * Identity helper that pins a spec's literal types and validates its fields. * Misconfiguration fails loud at the owning package's module load, before any * medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version * that is not a non-negative integer, or a global schema that accepts `null` diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index d9070874ca..52a09a0185 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -1,21 +1,21 @@ /** * Backend-facing vocabulary of the storage hub: a backend owns one medium - * (a file-tree root, a database file) and exposes data-shape facets over it. - * This module is the normative contract text for backend implementers; the - * shared conformance suite in `tests/contract.ts` asserts every clause. + * (a file-tree root, a database file) and exposes operation groups over it. + * This module defines the normative contract text for backend implementers; the shared + * conformance suite in `tests/contract.ts` checks every rule. * @module @deepseek-ai/dsh-storage/src/backend */ -/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ +/** Allowed format for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/ /** * One registered backend. A backend owns exactly one medium and shares its * lifecycle across all facets; facets are optional members — a backend that - * cannot serve a shape simply omits it, and resolution fails loud instead. + * cannot serve a data kind simply omits it, and resolution fails loud instead. */ export interface StorageBackend { - /** Key-value data shape; absent when this backend cannot serve it. */ + /** Key-value operations; absent when this backend cannot serve them. */ readonly kv?: KvFacet /** diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 01daa65f77..193e3b8dea 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-acp/README.md -README.md: 33711eb93c38d93472b3b625a86721c1365ac8d9 -README.zh.md: e57010f395e4ebae9b2e909bf63b7ffa1a90afe2 +README.md: 3bccddbca021bed1f8bf5766b9575f3bd7441669 +README.zh.md: 80afd65e5f4815042f05c22e4597bbb2677fb4fc diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 33711eb93c..3bccddbca0 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -28,7 +28,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `command` | required | Executable spawned for each run. | | `args` | `[]` | Command arguments. | | `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | -| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | +| `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first `allow_once` or `allow_always` option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Positive grace after stdin EOF before platform termination; it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | | `disposeGraceMs` | `3000` | Positive POSIX grace after SIGTERM before SIGKILL (Windows force-terminates directly); it cannot exceed [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md). | diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index e57010f395..80afd65e5f 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -28,7 +28,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 | `command` | 必填 | 每次运行时 spawn 的可执行文件。 | | `args` | `[]` | 命令参数。 | | `cwd` | 父会话 cwd | 子进程及其 ACP 会话的工作目录覆盖值;不得为空。相对值会在加载时以 harness 启动目录为基准解析,结果必须指向 harness 可以进入的目录。 | -| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个允许形态的选项。 | +| `permission` | `reject` | 自动回答权限请求:拒绝,或选择第一个 `allow_once` 或 `allow_always` 选项。 | | `env` | `{}` | 显式子进程环境,叠加到已清理凭据的父进程环境之上。 | | `disposeEofGraceMs` | `6000` | stdin EOF 之后、平台终止之前的宽限时间须为正值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | | `disposeGraceMs` | `3000` | POSIX 在 SIGTERM 后、SIGKILL 前的宽限时间(Windows 直接强制终止),须为正值且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md)。 | diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 7cc781ca15..af126b86de 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -42,7 +42,7 @@ export interface Config { /** * How to auto-answer the child's `session/request_permission` prompts: * `reject` (default — decline every prompt) or `allow` (approve via the first - * allow-shaped option). No prompt is surfaced to a human. + * `allow_once` or `allow_always` option). No prompt is surfaced to a human. */ permission: PermissionPolicy /** diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 17476be41c..7c82bfe9fb 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -248,8 +248,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe return Promise.resolve() }, requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { - // Auto-answer by the configured policy. `allow` selects the first - // allow-shaped option the child offered; if it offered none (or we + // Auto-answer by the configured policy. `allow` selects the first option + // whose kind is `allow_once` or `allow_always`; if the child offered none (or we // reject), answer `cancelled` so the child does not proceed. if (spec.permission === 'allow') { const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always') diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index b1df485644..de2fd37115 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -219,8 +219,9 @@ export interface SubagentResult { * The structured result after a requested `outputSchema` was successfully * satisfied. Requesting a schema does not guarantee presence: a provider can * end with `stopReason: 'error'` when the child fails or finishes without a - * valid capture. Shape is validated against the request schema by the - * provider; `unknown` here because the seam is schema-agnostic. + * valid capture. The structured value is validated against the requested + * output schema by the provider; `unknown` here because the seam is + * schema-agnostic. */ readonly structured?: unknown /** Why the run ended. A non-`completed` reason means `output` may be partial. */ diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index c0cfc56e24..5cdffadf38 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -436,7 +436,7 @@ export function formatToolSchemasSnapshot(initial: readonly unknown[], changes: } /** - * Parse and validate the stable top-level shape of a tool-schema sidecar. + * Parse and validate the stable top-level fields of a tool-schema sidecar. * * @param snapshot The JSON sidecar text. * @returns Its initial and changed-header schema sets. diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml index 9d85ffd0a5..ea7fbba8f3 100644 --- a/packages/support/invariants/README.i18n.yaml +++ b/packages/support/invariants/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/invariants/README.md -README.md: 71f7fb8b913610aad03694effad7746c0cb16499 -README.zh.md: ce2fc593a294a7f870bb42faa980fab462348e3b +README.md: 9a93187032b6f8e4f5d89e17e742baba41196ff9 +README.zh.md: 7f3fa1e23337e55a73928c3952aa4c925a5fb4e9 diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 71f7fb8b91..9a93187032 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -30,7 +30,7 @@ Session itself owns immutable, surface-valid log storage in every composition: i Publication and registration are exhaustive; runtime assertions are deliberately not synthetic. A companion installs a check only when its package owns an observable event relationship or relevant mutable-data relationship. Confirming a required method, plugin name, injection, effect, or fixed pure-function result is a type, load, or unit-test concern rather than a runtime invariant. -When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their seam, composition-only packages, binaries, persistence adapters whose contracts require crash/round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. +When no plausible runtime relationship exists, the companion uses an empty installer with a package-specific leading `No runtime invariant:` comment explaining why. This is common for pure utilities, thin implementations whose behavior is already observed through their interface package, composition-only packages, binaries, persistence adapters whose contracts require crash and round-trip tests, and test-support packages. The explanation must be revisited when the owner gains mutable state or an event protocol. The current executable companions protect these relationships: @@ -66,7 +66,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -The standard agent spine mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. +The standard agent composition mounts the service and its four core stateful companions. Custom compositions explicitly add companions for other loaded packages whose contracts they want checked; filters can disable or select registrations without changing package entrypoints. Every ordinary Vitest topology mounts an explicitly enabled service and the current test package's companion. Focused suites cover valid and invalid observations for executable companions, while one exhaustive topology mounts all companions to prove registration and disposal wiring. diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md index ce2fc593a2..7f3fa1e233 100644 --- a/packages/support/invariants/README.zh.md +++ b/packages/support/invariants/README.zh.md @@ -30,7 +30,7 @@ interface Config { 发布和注册覆盖全部包;但不会为了覆盖全部包而人为编造运行时断言。只有当包拥有可观察事件关系或相关可变数据关系时,配套入口才安装检查。确认必需方法、插件名称、注入、effect 或固定纯函数结果属于类型、加载或单元测试关注点,而非运行时不变量。 -如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过 seam 观察的薄实现、仅组合包、二进制程序、约定需要崩溃/往返测试的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 +如果不存在合理的运行时关系,配套入口使用空 installer,并以包专用的前置 `No runtime invariant:` 注释说明原因。纯工具、行为已通过其接口包观察的薄实现、仅组合包、二进制程序、需要通过崩溃测试和往返测试验证其约定的持久化适配器和测试支持包通常属于此类。当 owner 获得可变状态或事件协议时,必须重新审视该说明。 当前可执行配套入口保护以下关系: @@ -66,7 +66,7 @@ ctx.plugin(InvariantService, { ctx.plugin(SessionInvariant) ``` -标准 agent 主干挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其约定的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 +标准 agent 组合挂载服务和 4 个核心有状态配套入口。自定义组合为希望检查其约定的其他已加载包显式添加配套入口;过滤器可以在不改变包入口的情况下禁用或选择注册。 每个普通 Vitest 拓扑都挂载显式启用的服务和当前测试包的配套入口。聚焦套件覆盖可执行配套入口的合法与违规观测,一个穷尽拓扑则挂载全部配套入口,以证明注册和 dispose 接线。 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..9f18c18271 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -281,7 +281,7 @@ const REPLAY_CHUNK_TYPES = new Set<StreamChunk['type']>([ const FROM_REQUEST_OPEN = '{{fromRequest:' const FROM_REQUEST_CLOSE = '}}' -/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +/** Collect every string leaf of one JSON-compatible value, in traversal order. */ function collectStrings(value: unknown, out: string[]): void { if (typeof value === 'string') { out.push(value) @@ -333,7 +333,7 @@ function substituteString(text: string, corpus: string): string { } } -/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +/** Deep-copy one JSON-compatible value with scripted placeholders resolved. */ function substituteValue(value: unknown, corpus: string): unknown { if (typeof value === 'string') { return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 60a7beb012..807bf66cf1 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -114,8 +114,8 @@ export class LocalTaskService extends TaskService { void hooks.done.then( (outcome) => { this.settle(task, outcome) }, (error: unknown) => { - // Contain a producer contract violation so cleanup and waiters cannot hang. - this.selfCtx.logger.warn(`tasks: task ${task.id} 'done' rejected (producer contract violation): ${String(error)}`) + // Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang. + this.selfCtx.logger.warn(`tasks: task ${task.id} producer done promise rejected (producer contract violation): ${String(error)}`) this.settle(task, { status: 'failed', detail: String(error) }) }, ) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..35d7e77ff2 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -201,7 +201,7 @@ describe('LocalTaskService reads and settlement', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('async listener boom')) }) - it('contains a rejecting done as a failed outcome (producer contract violation)', async () => { + it("contains rejection from the producer's done promise as a failed outcome (producer contract violation)", async () => { const ctx = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const p = producer() diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 0cfe48a6f2..8c2a7aa7dd 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -39,7 +39,7 @@ function validateTodos(value: unknown, fail: InvariantFailure): void { } /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Validate the package-owned event shape and ignore unrelated events. */ +/** Validate the package-owned event fields and ignore unrelated events. */ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { if (event.type === 'todo/write') validateTodos(event.data.todos, fail) } diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 575d066e0d..2ae9722dea 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -18,8 +18,8 @@ * current entries. Package verdicts and imported manifests are cached per * package name and never expire — plugin-set changes take effect on restart. * - * Manual `ctx.typert.register()` remains the escape hatch for contributions - * that do not ride a `./typert` artifact (hand-written contract schemas, + * Manual `ctx.typert.register()` remains available for contributions + * that do not use a `./typert` artifact (hand-written wire schemas, * tests, non-loader compositions). * * @module @deepseek-ai/dsh-typert-loader @@ -68,7 +68,7 @@ function typertExportOf(pkgName: string, exportsField: unknown): string | undefi const fallback = (target as Record<string, unknown>).default if (typeof fallback === 'string') return fallback } - throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] has an unsupported shape`) + throw new Error(`typert-loader: ${pkgName} exports["${TYPERT_HOST_EXPORT}"] must be a string or an object with a string default`) } /** diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index af5d451351..491538a533 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -356,7 +356,7 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/export-primitive' }) await ctx.loader.await() - await expect(mountTypertLoader(ctx)).rejects.toThrow('unsupported shape') + await expect(mountTypertLoader(ctx)).rejects.toThrow('must be a string or an object with a string default') }) it('caches a negative verdict for loader entries without a package root', LOADER_TEST_TIMEOUT, async () => { diff --git a/packages/util/retention/src/index.ts b/packages/util/retention/src/index.ts index 1aca8b6aaf..73f44191a8 100644 --- a/packages/util/retention/src/index.ts +++ b/packages/util/retention/src/index.ts @@ -140,7 +140,7 @@ function assertBudget(value: number, name: string): void { * * Grouping, sorting, path mapping, per-unit preview truncation, and any * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing - * more. The caller pushes already-shaped units and, after {@link finish}, + * more. The caller pushes prepared logical units and, after {@link finish}, * groups/sorts the retained subset itself. */ export class ItemRetainer<T> { @@ -160,7 +160,7 @@ export class ItemRetainer<T> { * and counted as omitted. Callers keep pushing all observed units, so the final * {@link Omitted} count is exact. * - * @param item The already-shaped logical unit (path, flat match, source). + * @param item The prepared logical unit (path, flat match, source). * @returns The per-push {@link PushDecision}. */ push(item: T): PushDecision { @@ -253,7 +253,7 @@ export class TextRetainer { private suffixHeld = 0 private total = 0 - /** @param strategy One of the {@link TextRetentionStrategy} shapes; byte budgets must be non-negative integers. */ + /** @param strategy One {@link TextRetentionStrategy} variant; byte budgets must be non-negative integers. */ constructor(strategy: TextRetentionStrategy) { switch (strategy.kind) { case 'head': diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index f8f32c568a..aad4d8728c 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -58,7 +58,7 @@ export const Config: z<Config> = z.object({ fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS), }) -/** The shape after schemastery applies its defaults to every field. */ +/** Complete config after schemastery applies every field default. */ type ResolvedConfig = Required<Config> /** Configured count, timeout, and character caps must be positive integers. */ diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index 6e1e6b2bf6..a5636b37ee 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -55,7 +55,7 @@ export const Config: z<Config> = z.object({ userAgent: z.string().default(DEFAULT_USER_AGENT), }) -/** The shape after schemastery applies its defaults to every field. */ +/** Complete config after schemastery applies every field default. */ type ResolvedConfig = Required<Config> /** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */ diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index ff71d11c60..a41d1f8344 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -224,7 +224,7 @@ function resolveRedirect(location: string, base: URL): URL { /** * Translate a thrown fetch/stream error into a `WebError`, classified by the - * deadline signal rather than the error's shape (which differs by phase: the + * deadline signal rather than the thrown value (which differs by phase: the * request-phase `fetch` rejects with the abort reason, while the read-phase * reader surfaces a bare `AbortError`). `timeoutOf(signal, 'WEB_FETCH_TIMEOUT')` * recovering OUR reason means our timeout fired (`WEB_FETCH_TIMEOUT`); any other diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 59da2e33e0..ded152cc81 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -133,7 +133,7 @@ export class WebService extends Service { * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. - * @param request - the query plus result-shaping options. + * @param request - the query and optional result limit. * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index 316b6b5c11..3ac4344ace 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,7 +1,7 @@ /** * Vocabulary for the web capability seam (`ctx.web`). Search and fetch deliberately share one * seam so provider selection, cancellation, errors, and product configuration have one owner, - * while retaining separate request and result shapes. + * while retaining separate request and result types. * @module @deepseek-ai/dsh-web/types */ diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 219318f8f3..209ac7758c 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md -README.md: 4b75ce5b6a248949f135bfe1883680a645a4c774 -README.zh.md: 930da9d0975cd334a7a7cabf59958a9e11106861 +README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53 +README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2 diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 4b75ce5b6a..29896bee0f 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns schema and lifecycle shaping over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. +The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. This package owns the model-facing schema and run lifecycle over [`ctx.workflows`](../workflow/README.md); script parsing, execution, caps, and cancellation live behind the seam, while the consumer retains ownership of the parent-facing schema and result envelope. ## What the model sees diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 930da9d097..12e1ecd893 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 塑造 schema 和生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 +面向模型的 **`workflow` 工具**:运行一段扇出 subagent 的 JavaScript 编排脚本,并返回脚本的最终值。本包负责基于 [`ctx.workflows`](../workflow/README.md) 定义面向模型的 schema 和运行生命周期;脚本解析、执行、上限与取消位于 seam 之后,消费方仍负责面向父级的 schema 和结果包络。 ## 模型看到的内容 diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 2531a72241..6c1e9b19bb 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -1,6 +1,6 @@ /** * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out - * subagents, and return the script's final value. Pure schema + lifecycle shaping — script + * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script * parsing, execution, caps, and cancellation live behind `ctx.workflows` * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model * sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool diff --git a/packages/workflow/workflow-workerthread/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts index 5412345178..5ff48f606b 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,6 +1,6 @@ /** - * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against the shape - * contract and reject everything else loud, every violation named. Meta arrives as schema-checked + * Meta validation checks caller-provided DATA against the {@link WorkflowMeta} + * contract and rejects every violation by name. Meta arrives as schema-checked * JSON data, never evaluated script text; evaluating it on the host could run getters outside the * worker timeout that exists to isolate model-written code. * @module @deepseek-ai/dsh-workflow-workerthread/meta diff --git a/packages/workflow/workflow-workerthread/src/realm.ts b/packages/workflow/workflow-workerthread/src/realm.ts index cdcc86f8a0..546a4b56e1 100644 --- a/packages/workflow/workflow-workerthread/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -1,7 +1,7 @@ /** * Materializes values leaving the script vm into plain JSON before they cross the worker * boundary, and renders thrown script values without rejecting the run. The walk rejects - * lossy JSON shapes but trusts model-written workflow scripts: getters and proxy traps may + * values that JSON cannot preserve but trusts model-written workflow scripts: getters and proxy traps may * run, and the vm is not a security boundary. The worker provides host-loop isolation and * forced termination, not hostile-value containment. See * .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale. @@ -22,7 +22,7 @@ export class MaterializeError extends Error { * fall back to `message`, then `String()`. Reading those properties MAY run * script code (a getter, `toString`) — accepted under the module's trust * premise; if that code itself throws, a fixed label is returned instead. - * @param error - the thrown value, of any shape and any realm. + * @param error - any value thrown in the host or worker realm. * @returns human-readable text for the failure report; prefers the stack. */ export function renderThrown(error: unknown): string { @@ -40,7 +40,7 @@ export function renderThrown(error: unknown): string { } /** - * Whether an object's prototype chain is data-shaped: `null`, or a prototype + * Whether an object's prototype chain represents a plain data object: `null`, or a prototype * whose own prototype is `null` (the realm's `Object.prototype` — which we * cannot compare by identity across realms). A `Date`/`Map`/class instance * has a longer chain and is rejected. @@ -87,9 +87,9 @@ function materialize(value: unknown, path: string, seen: Set<object>): unknown { case 'bigint': throw new MaterializeError(path, 'bigints are not JSON data') case 'function': - throw new MaterializeError(path, 'functions cannot cross the workflow value boundary') + throw new MaterializeError(path, 'functions are not plain JSON data') case 'symbol': - throw new MaterializeError(path, 'symbols cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbols are not plain JSON data') case 'undefined': throw new MaterializeError(path, 'undefined is not JSON data') case 'object': @@ -122,7 +122,7 @@ function materializeArray(value: unknown[], path: string, seen: Set<object>): un } } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data') } return out } @@ -132,7 +132,7 @@ function materializeObject(value: object, path: string, seen: Set<object>): Reco throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)') } if (Object.getOwnPropertySymbols(value).length > 0) { - throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow value boundary') + throw new MaterializeError(path, 'symbol-keyed properties are not plain JSON data') } const out: Record<string, unknown> = {} // Object.keys = own enumerable string keys, matching JSON.stringify's diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 6bc749cbd9..e807d1ef5b 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,5 +1,5 @@ /** - * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it + * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result serialization; it * never touches Cordis. Script values leaving the realm are materialized as plain JSON before * messaging. Values entering the trusted model-written realm are passed directly; `args` alone is * cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model. diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 12386659bc..bdf933a3f7 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -1,6 +1,6 @@ /** * Workflow seam vocabulary: the request/run/result types a workflow engine - * consumes and produces, plus the payload shapes of the `workflow/*` events. + * consumes and produces, plus the fields in the `workflow/*` event payloads. * Types only (plus the id-brand factory), per the package convention. * * @module @deepseek-ai/dsh-workflow/types @@ -57,8 +57,8 @@ export interface WorkflowMeta { /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's - * schema-validated call; the engine validates `meta`'s shape and rejects loud + * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; + * the engine validates `meta` against its schema and rejects loud * before anything runs) — an engine never evaluates script text to obtain * them. `parent` is REQUIRED — every `agent()` the script spawns is * attributed to it (cwd, lineage, depth flow through the subagent seam). @@ -66,7 +66,7 @@ export interface WorkflowMeta { export interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */ script: string - /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + /** The workflow's identity fields as plain JSON data, validated by the engine. */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 9e0a17e270..0086d8519f 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/README.md -README.md: 27637edb9d4d5e8714fe379a00b6aae3af1a541f -README.zh.md: b0587b25b66750780768786e02b02037dd10ec0a +README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d +README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb diff --git a/python/README.md b/python/README.md index 27637edb9d..6ab9de6814 100644 --- a/python/README.md +++ b/python/README.md @@ -13,7 +13,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com ## Behavior -The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client owns channel selection and default-configuration injection; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete resolution and configuration contracts. +The SDK starts the matching bundled runtime unless the caller selects an explicit channel. The client selects the channel and supplies default configuration; the runtime itself always requires an explicit configuration. The [SDK reference](sdk/README.md) and [runtime carrier reference](sdk-runtime/README.md) own the complete runtime-selection and configuration contracts. ## Contributor workflows diff --git a/python/README.zh.md b/python/README.zh.md index b0587b25b6..82fca59779 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -13,7 +13,7 @@ ## 行为 -除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端负责选择通道和注入默认配置;运行时本身始终要求显式配置。完整的解析与配置约定分别由 [SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义。 +除非调用方选择显式通道,否则 SDK 会启动匹配的内置运行时。客户端选择通道并提供默认配置;运行时本身始终要求显式配置。[SDK 参考](sdk/README.md)和[运行时载体参考](sdk-runtime/README.md)定义完整的运行时选择与配置约定。 ## 贡献者工作流 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 68ea79ea7b..8585d16982 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — Repository scripts -Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer. +Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer. diff --git a/scripts/archived-agent-notes.ts b/scripts/archived-agent-notes.ts index bdce541ab0..39786cca27 100644 --- a/scripts/archived-agent-notes.ts +++ b/scripts/archived-agent-notes.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto' import { basename } from 'node:path' import { AGENT_NOTE_CLASSES } from './agent-note-tree.ts' -/** Versioned shape of the frozen-content manifest. */ +/** Versioned fields in the frozen-content manifest. */ export interface ArchiveManifest { version: 1 files: Readonly<Record<string, string>> diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index aa3bae0b6d..d43c87f08b 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -1,5 +1,5 @@ /** - * Pins shared client-bundle preset contracts: the module-edge purity gate and + * Pins shared client-bundle preset rules: the module-edge purity gate and * the physical watch dependencies hidden behind virtual CSS Modules. */ import { fileURLToPath } from 'node:url' diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index 3f878be13c..befc6156e1 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -81,7 +81,7 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri /** * Every event name a `declare module 'cordis'` Events merge declares in one * module body. Names are the literal member keys (`'agent/created'`), read - * from method and property members alike so a declaration shape the projector + * from method and property members alike so a declaration form the projector * would reject still enters the exhaustiveness scan. * @param body - The cordis module augmentation block. * @param sf - Owning source file (for computed-name text extraction). diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index 8f20f54424..b560567014 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -1,6 +1,6 @@ /** * Heavy suites the coverage aggregate runs uninstrumented in a parallel gate. - * Membership contract: a suite qualifies only when every coverage-measured + * Membership rule: a suite qualifies only when every coverage-measured * file it executes in-process (`coverage.include` spans package src trees; * typert generator src is threshold-excluded in vitest.config.ts) is already * fully covered by other suites, so removing it from the instrumented run diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts index dc5a6a9d05..b054126aec 100644 --- a/scripts/doc-typecheck-paths.ts +++ b/scripts/doc-typecheck-paths.ts @@ -1,6 +1,6 @@ /** Map one workspace source alias target to its declaration-build target. */ export function builtDeclarationPath(candidate: string): string { - // Two workspace shapes exist: whole-package entries end in /src, subpath + // Two workspace path forms exist: whole-package entries end in /src, subpath // wildcards (apiproxy's browser-safe /api and /client channels) in /src/*. if (candidate.endsWith('/src')) { return `${candidate.slice(0, -'/src'.length)}/lib/types` diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index efdb03eaad..87de14ea6f 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -221,7 +221,7 @@ const { primary: all, derivatives } = partitionPairedMarkdownDerivatives( const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') // Only compile-eligible fences belong in the opt-out ratio; every other skipped -// kind has an independent verifier named in BlockKind's contract above. +// kind has an independent verifier named in the BlockKind rules above. const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index a79ffdd47f..12732b9aa3 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -2,7 +2,7 @@ * Generate `docs/config-catalog.md` from package entry points, config types, * JSDoc, and static Schemastery schemas. Every package must classify, referenced * types must resolve without collisions, and every enumerable schema path must - * exist on the declared config type. External and dynamic shapes stay unknown; + * exist on the declared config type. External and dynamic types stay unknown; * declared runtime-only fields need not appear in the schema. `--check` verifies * the committed artifact. */ @@ -220,7 +220,7 @@ interface World { } /** How a schema key path fared against the declared config type: definitely - * present, definitely absent, or crossing a shape the walk cannot enumerate + * present, definitely absent, or crossing a type the walk cannot enumerate * (only `missing` is a violation — `unknown` must never mis-report). */ type PathLookup = 'found' | 'missing' | 'unknown' @@ -307,7 +307,7 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul /** * Walk a schema key path against a declared type. This is a PRESENCE check, - * not a shape check: it answers "does the declared config type have a member + * not a runtime value check: it answers "does the declared config type have a member * here", resolving interfaces (heritage included), type aliases, literals, * intersections, unions, arrays, indexed access, pass-through utility * wrappers, and type references across package-local and workspace imports. @@ -412,7 +412,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression { * Statically walk a schemastery schema expression to its key paths plus the * packages whose schemas an intersect composes. A key path is the top-level * key or a nested path through object/array compositions (`agents[].id`). - * Handles the shapes the repo declares — `z.object({…})` (possibly behind + * Handles the declaration forms the repo uses — `z.object({…})` (possibly behind * chained calls) and `z.intersect([X.Config, …])` — and hard-errors on * anything else, so a schema the walk cannot see fails the gate instead of * silently thinning it. Nested values that are neither `object` nor `array` @@ -521,7 +521,7 @@ function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null): function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] { const fromArray = (expr: ts.Expression, where: string): string[] => { if (!ts.isArrayLiteralExpression(expr)) { - violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`) + violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new declaration form.`) return [] } return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf)) @@ -727,7 +727,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { } // Fold composed schemas' key paths in, then check each path against the type. - // Only a definite miss fails; shapes the walk cannot enumerate stay unknown. + // Only a definite miss fails; types the walk cannot enumerate stay unknown. const byName = new Map(entries.map(e => [e.pkg, e])) for (const entry of entries) { if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f2349d7c13..885e0cf9d2 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -111,16 +111,16 @@ export const SERVICE_PAGE: Record<string, string> = { */ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', - configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns the launcher contract', - launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns the launcher contract', + configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', + launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', - headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns the launcher contract', - launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns the launcher contract', + headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns this launcher contract', + launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns this launcher contract', lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the surface', apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface', appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface', connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface', - chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface', + chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API', command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface', conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', @@ -483,13 +483,13 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = { 'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API', TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md', InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md', - LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', + LocaleDict: 'service-local dictionary fields are owned by packages/client/i18n/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts', WebUpgradeRoute: 'upgrade route registration contract is owned by packages/host/webserver/src/index.ts', InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md', - KnobState: 'projection unit state shape is owned by packages/interaction/permission/README.md', + KnobState: 'projection unit state fields are owned by packages/interaction/permission/README.md', PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md', @@ -752,7 +752,7 @@ export function maybeRecordPair(pageRel: string, before: Map<string, Buffer>, sc // after review, never silently by regeneration. return false } - // The record must be exactly the well-formed two-entry shape for THIS pair; + // The record must contain exactly the two valid entries for THIS pair; // a malformed or renamed-key sidecar is the pairing gate's problem to // report, never something regeneration silently repairs into validity. const recorded = parsePairMeta(meta) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e9f2f806ae..aa875ca369 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -521,7 +521,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['workflow-workerthread'], consumers: ['tool-workflow', 'tool-ralph'], - note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', + note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.', }, ] @@ -815,7 +815,7 @@ export class EventRelationCollector { * Return every indexed call resolving to one local helper declaration. * Fast path: when every same-file reference to the non-exported helper is * provably a direct callee, module scoping confines all of its calls to that - * file, so only that file is indexed. Any other reference shape may alias + * file, so only that file is indexed. Any other reference form may alias * the function value outward, so the original full package-source index * decides instead. */ @@ -1155,7 +1155,7 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) } // Every declared event needs a dispatcher: zero means dead vocabulary or an - // unrecognized semantic dispatch shape. Listener-free extension points remain + // unrecognized semantic dispatch form. Listener-free extension points remain // valid. Client-declared events are exempt: the relation scan seeds the HOST // aggregate program only (host+client cannot share one program — the cordis // Context merges collide), so client dispatch sites are structurally @@ -1168,8 +1168,8 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin if (undispatched.length > 0) { throw new Error( `event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} ` - + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses ` - + '(teach scripts/gen-doc-graphs.ts the shape)', + + `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch form the semantic scan misses ` + + '(teach scripts/gen-doc-graphs.ts that form)', ) } const declared = new Set(events.map(event => event.name)) @@ -1262,9 +1262,9 @@ function renderLifecycle(): string { '', '`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.', '', - 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch.', + 'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.', '', - 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.', + 'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request construction, steering, continuation, and errors.', '', ...maintenanceFooter(maintenance), ].join('\n') @@ -1274,7 +1274,7 @@ function renderToolPipeline(): string { const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs' return [ ...generatedHeader('Tool Execution Pipeline'), - 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them.', + 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.', '', '```mermaid', 'flowchart TD', @@ -1380,7 +1380,7 @@ function renderIndex(docs: GraphDoc[]): string { const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode' return [ ...generatedHeader('Documentation Graph Index'), - 'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).', + 'These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).', '', 'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).', '', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index c9b1f41dbf..aa3198057b 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -139,7 +139,7 @@ describe('parseVendoredRows', () => { expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true) }) - it('yields nothing when the table shape changes, so the generator fails loud', () => { + it('yields nothing when the table columns change, so the generator fails loud', () => { expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) }) @@ -215,7 +215,7 @@ describe('parsePyprojectRequirements', () => { ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest']) }) - it('accepts dependency-group includes and rejects unsupported requirement shapes', () => { + it('accepts dependency-group includes and rejects unsupported requirement forms', () => { expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n')) .toEqual(['pytest']) expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 9802adacf2..c2ab21688a 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -475,7 +475,7 @@ function collectPythonRequirementArray( } } -/** Read an optional TOML table and reject a present value of another shape. */ +/** Read an optional TOML table and reject a present non-table value. */ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined { if (value === undefined || isTomlTable(value)) return value throw new Error(`gen-third-party-notices: ${location} must be a table.`) @@ -487,7 +487,7 @@ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: * `[build-system]`, `dependencies` under `[project]`, and every key under * `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser * owns comments, quoted keys, escapes, and array boundaries; unsupported - * requirement shapes fail instead of disappearing from the notices. + * requirement forms fail instead of disappearing from the notices. * @param text - the complete `pyproject.toml` contents. * @returns the local project name and declared requirement names. */ diff --git a/scripts/gen-translation-brief.ts b/scripts/gen-translation-brief.ts index a979d2652a..0a0d03cc61 100644 --- a/scripts/gen-translation-brief.ts +++ b/scripts/gen-translation-brief.ts @@ -7,7 +7,7 @@ * the narrowest safe granularity — code-fence-only splice, changed * Markdown units, heading sections, whole document — and `--apply` writes * the computed counterpart for pairs whose change is code-fence-only. - * The briefing contract lives in `scripts/translation-brief.ts`; the + * The briefing rules live in `scripts/translation-brief.ts`; the * consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`. */ diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index b1f57eba7b..0db617ba59 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -15,7 +15,7 @@ interface Profile { // A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2 // mapped @typescript-eslint/* to typescript/* and four extension rules to their // Oxlint core equivalents. These fingerprints pin the resulting repository -// contract; they do not re-evaluate that deleted baseline or track its preset. +// snapshot; they do not re-evaluate that deleted baseline or track its preset. const profiles = { source: { count: 88, @@ -84,7 +84,7 @@ describe('Oxlint repository rule fingerprint', () => { } const overrides: readonly unknown[] = parsed.overrides - it('pins the complete override shape', () => { + it('pins every override field', () => { expect(overrides).toHaveLength(8) }) diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 54318ca94f..eeae171a0b 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -19,7 +19,7 @@ interface PackageManifest { devDependencies?: Record<string, string> } -/** One package and the files participating in its invariant publication contract. */ +/** One package and the files participating in its invariant publication rules. */ export interface PackageInvariantOwner { readonly dir: string readonly manifestPath: string @@ -53,7 +53,7 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] { }) } -/** Return all violations of the package-invariant companion contract. */ +/** Return all violations of the package-invariant companion rules. */ export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] { const violations: PackageInvariantViolation[] = [] for (const owner of packageInvariantOwners(root)) { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index cbcf8504d5..e8055db866 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -341,7 +341,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] { return gates } -/** Active Node major used to scope version-specific compatibility contracts. */ +/** Active Node major used to select version-specific compatibility checks. */ function runningNodeMajor(): number { const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10) if (!Number.isSafeInteger(major)) { @@ -473,7 +473,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // The heavy suites run uninstrumented beside the thresholded gate: their // compiler- and subprocess-bound fixtures pay a multiple of their runtime // under v8 instrumentation while contributing nothing the thresholds need -// (membership contract in scripts/coverage-exempt.ts). +// (membership rules in scripts/coverage-exempt.ts). // // DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel // gates split it instead of each claiming it whole (the failover pool's diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 587d59a8bd..bea681d55a 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,31 +4,31 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering idiom over literal renderings, and localize metaphors instead of transplanting them.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required shape; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only, without looking at the source; awkward phrasing is easier to notice without source-language anchoring. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Is the heading hierarchy and order, list shape and count, ordered-list start, table shape, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or transplanted metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that must be fixed before a new release. A release must not include an unresolved FIXME unless reviewers explicitly approve merging the change without fixing it.`\n- Bad: `FIXME——新版本之前必须修复的问题。除非评审者明确批准带着问题合入,否则版本里不能有未解决的 FIXME。`\n- Good: `FIXME:新版本发布前必须修复的问题。除非评审者明确批准在不修复的情况下合并该更改,否则发布版本不得包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)约定](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, enforcement gate, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md).\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nCounterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、强制门禁、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何无法确定的情形都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n这里的对侧文件由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 2c00bc7439..db3cae7073 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -137,7 +137,7 @@ describe('global test invariant host', () => { .toEqual(Object.keys(testInvariantCompanions).sort()) }) - it('loads and executes every source companion through the real Loader shape', async () => { + it('loads and executes every source companion through the real Loader setup', async () => { const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName])) const registrations = new Map<string, string>() const loader = Object.create(Loader.prototype) as Loader diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index d6f05eee90..9235f34e46 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -24,7 +24,7 @@ declare global { } } -/** Loader-safe shape shared by every package invariant companion. */ +/** Loader-safe exports shared by every package invariant companion. */ export interface TestInvariantCompanion { readonly name: string readonly inject: readonly string[] diff --git a/scripts/translation-pairing-git.ts b/scripts/translation-pairing-git.ts index cda27426c6..5f1ddd4fc0 100644 --- a/scripts/translation-pairing-git.ts +++ b/scripts/translation-pairing-git.ts @@ -54,7 +54,7 @@ export interface GitIndexBlob { * @param root - Repository root. * @param path - Repository-relative path. * @returns The stage-zero blob, or `undefined` when the path is absent. - * @throws Error when the path is unmerged or has an invalid index shape. + * @throws Error when the path is unmerged or its index entries are not a valid merge state. */ export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined { const output = runGit( diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index e80605c14b..6c6e3b8226 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -80,7 +80,7 @@ const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/ /** * Parse a `foo.i18n.yaml` consistency record into basename → recorded blob * hash, or undefined when any non-comment line deviates from the exact - * `<basename>.md: <40-hex>` shape or repeats a key. Consumers must + * `<basename>.md: <40-hex>` format or repeats a key. Consumers must * additionally require exactly the two expected basenames — a renamed key is * a malformed record, never a silently-missing entry. * @param content - Sidecar file text. @@ -118,7 +118,7 @@ export function renderPairMeta(source: string, sourceHash: string, zh: string, z ].join('\n') } -/** Validated shape of `scripts/translation-pairing.manifest.json`. */ +/** Validated fields of `scripts/translation-pairing.manifest.json`. */ export interface TranslationPairingManifest { /** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */ excluded: string[] diff --git a/scripts/translation-prompt.spec.ts b/scripts/translation-prompt.spec.ts index e3b14f169e..65160896c7 100644 --- a/scripts/translation-prompt.spec.ts +++ b/scripts/translation-prompt.spec.ts @@ -21,7 +21,7 @@ const retainedExamples = [ ['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'], ['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'], ['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'], - ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'], + ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to notice when you read the translation without comparing it with the source', '不对照原文阅读译文时,更容易察觉别扭的表达'], ['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'], ['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'], ['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'], @@ -44,7 +44,7 @@ describe('translation prompt rendering', () => { expect(zh).toContain('from Chinese to English') }) - it('retains every v4 embedded example', () => { + it('contains every embedded example', () => { for (const example of retainedExamples) { for (const fragment of example) expect(document).toContain(fragment) } diff --git a/scripts/translation-prompt.ts b/scripts/translation-prompt.ts index aaf114efe8..91bd8e3210 100644 --- a/scripts/translation-prompt.ts +++ b/scripts/translation-prompt.ts @@ -169,7 +169,7 @@ function unescapeResponseBody(value: string): string { }).join('\n') } -/** Serialize a response in the exact escaped three-section shape the prompt requests. */ +/** Serialize a response in the exact escaped three-section format the prompt requests. */ export function renderTranslationResponse(response: TranslationResponse): string { return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n</${section}>`).join('\n\n') } @@ -178,7 +178,7 @@ export function renderTranslationResponse(response: TranslationResponse): string * Parse the three-section response. Sections must each appear exactly once * and in order; escaped delimiter lines in Markdown bodies are restored. * A fenced ```xml wrapper around the whole response is tolerated, matching - * the shape some models echo back from the prompt's own example. + * the wrapper some models copy from the prompt's own example. */ export function parseTranslationResponse(text: string): TranslationResponse { let body = text.trim() diff --git a/scripts/verify-agent-note-classification.ts b/scripts/verify-agent-note-classification.ts index 776e155f4f..588a32c3fc 100644 --- a/scripts/verify-agent-note-classification.ts +++ b/scripts/verify-agent-note-classification.ts @@ -1,6 +1,6 @@ /** * Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules - * are shared with `agent-note-tree.ts`; the closed classification contract lives + * are shared with `agent-note-tree.ts`; the closed classification rules live * in `.agents/notes/README.md`. */ diff --git a/scripts/verify-agent-note-format.ts b/scripts/verify-agent-note-format.ts index 39018588e6..0ca93b298e 100644 --- a/scripts/verify-agent-note-format.ts +++ b/scripts/verify-agent-note-format.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts' -/** The date the format contract landed; the grandfather comment is valid only before it. */ +/** The date these format rules took effect; the grandfather comment is valid only before it. */ const FORMAT_ADOPTED = '2026-07-05' /** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */ diff --git a/scripts/verify-archived-agent-notes.ts b/scripts/verify-archived-agent-notes.ts index ca27dd8852..d2f12f826e 100644 --- a/scripts/verify-archived-agent-notes.ts +++ b/scripts/verify-archived-agent-notes.ts @@ -99,7 +99,7 @@ if (!writeMode) { } if (errors.length > 0) { - console.error('verify-archived-agent-notes: archive contract violated:') + console.error('verify-archived-agent-notes: archive rules violated:') for (const error of errors) console.error(` ${error}`) process.exit(1) } diff --git a/scripts/verify-config-source-ownership.ts b/scripts/verify-config-source-ownership.ts index b0f4b89cdf..0684124215 100644 --- a/scripts/verify-config-source-ownership.ts +++ b/scripts/verify-config-source-ownership.ts @@ -19,7 +19,7 @@ const SHIPPED_CONFIG_GLOBS = [ 'python/*/src/**/cordis.yml', ] -/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */ +/** Ordinary single-line configuration forms this source check rejects; not full YAML analysis. */ const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/ /** Return every forbidden inline environment form in shipped configuration. */ diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 988c3a2e38..bcc2c2cfbd 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -75,9 +75,9 @@ function unwrapExpression(e: ts.Expression): ts.Expression { /** * Classify inline callable annotations. Mixed callable literals fail closed; - * other annotations are ordinary value shapes. + * other annotations are ordinary value types. * @param type - the declarator's type annotation. - * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape. + * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable type. */ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null { if (ts.isFunctionTypeNode(type)) return type @@ -446,7 +446,7 @@ function checkScope( if (ts.isExportAssignment(stmt)) { if (stmt.isExportEquals) { // `export =` has no ESM consumer surface in this repo and the walk - // cannot classify its operand's shape; refuse rather than fail open. + // cannot classify its operand's type; refuse rather than fail open. w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`) continue } diff --git a/scripts/verify-package-invariants.ts b/scripts/verify-package-invariants.ts index 32e34539fa..dc686b1018 100644 --- a/scripts/verify-package-invariants.ts +++ b/scripts/verify-package-invariants.ts @@ -1,4 +1,4 @@ -/** Verify package-owned invariant source and publication contracts. */ +/** Verify package-owned invariant source and publication rules. */ import { resolve } from 'node:path' import { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1a1de87d09..0a202e6142 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -217,12 +217,12 @@ function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>): return { blocks } } -/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */ +/** GitHub-style fragment for the simple ASCII nested titles allowed by these rules. */ function headingFragment(title: string): string { return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-') } -/** A direct stable system-prompt contribution, as named by the README contract. */ +/** A direct stable system-prompt contribution, as named by the README rules. */ function isDirectSystemPromptSurface(title: string): boolean { return /\bsystem prompt\b/i.test(title) } diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 95a2fe9747..e91661e5e4 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -291,6 +291,6 @@ if (errors.length === 0) { process.exit(0) } -console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):') +console.error('verify-translation-pairing: bilingual pairing rules violated (see docs/i18n/README.md):') for (const message of errors) console.error(` ${message}`) process.exit(1) diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md index 30c5cc7bdc..119725274c 100644 --- a/skills/dsh-upgrade/SKILL.md +++ b/skills/dsh-upgrade/SKILL.md @@ -33,7 +33,7 @@ The worktree name is always `staging-<timestamp>` under `<source>`, never derive 3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. 4. In the main clone, create `refs/dsh-upgrade/recovery-<timestamp>` at the recorded old staging tip and `dsh-upgrade/prepare-<timestamp>` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-<timestamp>` and record its object ID. Add a fresh worktree `<source>/staging-<timestamp>` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. 5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. -6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds a current, independently useful contract absent upstream. Abort without changing the installed launcher when resolution is uncertain. +6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds current, independently useful behavior or rules absent upstream. Abort without changing the installed launcher when resolution is uncertain. 7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. 8. Point `dsh-staging/<timestamp>` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. 9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. From 51b2fc35f336fec5ccbfc14342b5fe6d07d45ab9 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Mon, 10 Aug 2026 16:15:30 +0800 Subject: [PATCH 80/81] docs: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index bea681d55a..7f2c71353e 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that must be fixed before a new release. A release must not include an unresolved FIXME unless reviewers explicitly approve merging the change without fixing it.`\n- Bad: `FIXME——新版本之前必须修复的问题。除非评审者明确批准带着问题合入,否则版本里不能有未解决的 FIXME。`\n- Good: `FIXME:新版本发布前必须修复的问题。除非评审者明确批准在不修复的情况下合并该更改,否则发布版本不得包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Translate link text; do not change link targets.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing `<final>`.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `<review>` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in `<review>`. A tentative rendering may appear in `<review>` but must not be silently adopted in `<translation>` or `<final>`, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/bash` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of `<translation>`, `</translation>`, `<review>`, `</review>`, `<final>`, or `</final>`, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n<translation>\n(First pass: the complete translation, written as natural target-language technical prose)\n</translation>\n\n<review>\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n</review>\n\n<final>\n(Complete final translation after corrections)\n</final>\n```\n\n## Self-Review Instructions\n\nAfter writing `<translation>`, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing `<review>`; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Are link targets and emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in `<review>`, then output the corrected complete document in `<final>`. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `<review>` and copy `<translation>` unchanged into `<final>`. If `<review>` contains only pending terminology notices, copy `<translation>` unchanged into `<final>`.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From cf5fbd02b8c6480cd00d2d9631d648eeefe758a7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Mon, 10 Aug 2026 16:34:29 +0800 Subject: [PATCH 81/81] fix(tasks-local): return the layer disposer directly and cover scoped teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ScopedLayers.effect` already returns an exact `() => void`, so the inherited `() => void dispose()` wrapper voided a void — two lint rules, four errors. The scoped layer's own teardown had no test, which is the registry-contribution disposal contract the testing policy requires and the only path that calls `TaskLayer.isEmpty()`: `ScopedLayers` prunes a scope's layer when its last contribution disposes. The new case mounts one plugin contributing both a surface and a listener into one scope, then unloads it and observes that the agents which joined that scope are refused again. Refs #2141 --- packages/tasks/tasks-local/src/index.ts | 6 ++--- .../tasks/tasks-local/tests/tasks.spec.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 146d3e4ca7..cb7de27ecd 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -238,23 +238,21 @@ export class LocalTaskService extends TaskService { } onTaskDone(listener: TaskDoneListener): () => void { - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.listeners.append(listener), { label: 'tasks.onTaskDone()' }, ) - return () => void dispose() } attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) - const dispose = this.layers.effect( + return this.layers.effect( this.ctx, layer => layer.surfaces.append(token), { label: 'tasks.attachSurface()' }, ) - return () => void dispose() } /** diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index bdaa31d975..6a7eb44d09 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -805,6 +805,30 @@ describe('LocalTaskService disposal', () => { expect(ownerEffects()).toHaveLength(0) }) + it('drops a scoped layer when its registrations dispose', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + const standing = createScope(ctx, {}) + // One mount contributes both kinds into the same layer, as `tool-tasks` + // does; unloading it must leave nothing serving the agents that joined it. + const mount = await standing.ctx.plugin({ + inject: ['tasks'], + apply(pluginCtx: Context) { + pluginCtx.tasks.attachSurface('tool-tasks') + pluginCtx.tasks.onTaskDone(() => {}) + }, + }) + const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx)) + ctx.agents.register(owner) + expect(() => ctx.tasks.start(producer({ owner }).spec)).not.toThrow() + + await mount.dispose() + + expect(() => ctx.tasks.start(producer({ owner }).spec)) + .toThrow('no control surface serves this agent') + }) + it('detaching the last surface re-arms the register fence', async () => { const ctx = new Context() await ctx.plugin(LocalTaskService)