Merge origin/master into worktree/ci-under-minute
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Windows write-permission semantics — inherited DACLs, not mode bits
|
||||
|
||||
Status: implemented
|
||||
|
||||
The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
|
||||
|
||||
## Problem
|
||||
|
||||
`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions.
|
||||
|
||||
Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding Agent Note.
|
||||
|
||||
## Decision
|
||||
|
||||
New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
|
||||
|
||||
Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy.
|
||||
|
||||
**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile.
|
||||
|
||||
**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists.
|
||||
|
||||
Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Windows-native durable JSONL publication
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-session-persistence-jsonl` publishes a session log lazily on the first append. The POSIX protocol writes a temp file, fsyncs it, links it to the final name, fsyncs the parent directory, and then removes the temp link. The parent-directory fsync is part of the durability contract: a crash after the namespace change must not lose the committed final name while leaving callers believing the session log materialized.
|
||||
|
||||
Windows has atomic namespace operations, but Node does not expose a POSIX-equivalent parent-directory fsync contract there. Treating Windows directory sync failures as success would silently weaken a durable backend. The Windows path therefore needs a different publication primitive rather than a conditional inside the POSIX `syncDir` helper.
|
||||
|
||||
## Decision
|
||||
|
||||
The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and encoded header plus initial event batch; POSIX and Windows then run separate publication protocols.
|
||||
|
||||
POSIX keeps the existing protocol: create the root and cwd bucket with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the bucket directory, then remove the redundant temp hard link.
|
||||
|
||||
Windows creates missing directories through a durable staging publish: create a random sibling directory, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Ignore Windows directory-sync failures.** Rejected because it reports a first append as durable without forcing the published namespace entry to stable storage.
|
||||
|
||||
**Use `CreateHardLinkW`.** Rejected because hard links are filesystem-dependent, do not publish directories, and expose no write-through option.
|
||||
|
||||
**Use replacement or transactional APIs.** `ReplaceFileW` has replacement semantics that conflict with same-id collision rejection, and Transactional NTFS is not recommended for new application designs.
|
||||
|
||||
## Consequences
|
||||
|
||||
The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and the logical JSONL record format do not change. The later [Zstandard encoding decision](2026-07-19-zstandard-jsonl-session-logs.md) applies before either platform publishes the opaque bytes.
|
||||
|
||||
Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, temp logs are fsync'd before publication, and the resulting log loads normally.
|
||||
|
||||
Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles.
|
||||
@@ -12,13 +12,13 @@ The implementation needs enough state to preserve real ownership and settlement
|
||||
|
||||
## Decision
|
||||
|
||||
The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; the cooperative prompt-assembly result is authoritative; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race.
|
||||
The runtime uses one mechanism per independent fact. Scope routing has an opaque carrier and shared layer store; each live registry object has one entry record; each create or resume operation has one transaction; typed same-process calls borrow readonly values; real data boundaries materialize once; the cooperative prompt-assembly result is authoritative; and worker/process code retains separate terminal and quiescence state only where different owners can genuinely race.
|
||||
|
||||
The design can be skimmed as seven choices:
|
||||
|
||||
| Problem | Authoritative mechanism |
|
||||
|---|---|
|
||||
| Select global plus one agent's registrations | Opaque scope key and routing carrier |
|
||||
| Select global plus one agent's registrations | Opaque scope key, routing carrier, and shared layer store |
|
||||
| Own one live agent or session | One registry entry captured by its disposer |
|
||||
| Coordinate create/resume | One `AgentCreationTransaction` |
|
||||
| Protect durable, queued, model, or wire data | Materialize once at that boundary |
|
||||
@@ -68,11 +68,11 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live
|
||||
|
||||
The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`.
|
||||
|
||||
### Registry reads overlay one exact map
|
||||
### Registry reads overlay one exact layer
|
||||
|
||||
Scope-aware registries store global contributions separately from identity-keyed local contributions. A read resolves the global layer and at most one local layer; it never traverses parentage.
|
||||
Scope-aware registries use `ScopedLayers` to own one eager global aggregate and lazily created identity-keyed aggregates. A read resolves the global layer and at most one exact local layer; it never creates state or traverses parentage. Registration visibility and Cordis effect ownership derive from the same context, and reclamation waits until the concrete layer's complete aggregate is empty ([decision](2026-07-12-scoped-layers-store.md)).
|
||||
|
||||
Each service retains its domain rule. Named prompt values and tools use local shadowing, tool restrictions filter globals before local tools are added, and events select listener audiences rather than registered data. Scope supplies identity and ownership, not a universal merge algorithm.
|
||||
Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved Code Mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view.
|
||||
|
||||
### Fused dispatch helpers prevent subject drift
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-12-scoped-layers-store.md: b850b6bcbb22401b386b4458b6d5c65a160c85cd
|
||||
2026-07-12-scoped-layers-store.zh.md: 8bfc0a0e8ec1e3de624ff8d9e48b7517833fc025
|
||||
@@ -0,0 +1,126 @@
|
||||
# Agent Note: Shared scoped-layer storage
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-12-scoped-layers-store.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Agent scoping ([decision](2026-07-08-agent-scope-contexts.md), [runtime design](2026-07-12-agent-scope-runtime-design.md)) gives scope-aware registries the same recurring shape: one global registration layer plus one exact agent layer. Seven registration facades use that shape: `tools.register`, `tools.restrict`, and `tools.guard` in `dsh-tools`; `SystemPrompt.section`, `SystemPrompt.tools`, and `SystemPrompt.variable` in `dsh-system-prompt`; and `CommandService.register` in `dsh-commands`.
|
||||
|
||||
Without a shared primitive, each facade repeats the lifecycle choreography around its domain state: derive visibility from the calling context, create a scoped container on demand, attach ownership to the same Cordis fiber, install undo before notifying observers, return Cordis's exact disposer, and reclaim empty scoped state. Separate maps and collection types also leave a service without one object representing a scope's complete contribution.
|
||||
|
||||
The duplicated code carries three non-obvious requirements:
|
||||
|
||||
- Visibility and ownership must come from the same context; accepting them separately permits a registration visible in one scope but disposed with another.
|
||||
- Undo must be collected before a change callback runs, so a throwing callback rolls the mutation back.
|
||||
- The public disposer must be the exact function returned by `ctx.effect()`; wrapping it breaks Cordis's identity-based ordered teardown.
|
||||
|
||||
The shared part is lifecycle and insertion-ordered storage, not registry policy. Tool restrictions, reserved transport handling, prompt evaluation timing, command normalization, exact diagnostics, and callback containment remain different domain contracts.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-scope` provides a key-agnostic `store.ts` implementation module. The package continues to peer on Cordis and `@deepseek-ai/dsh-invariants`, and its invariant companion remains unchanged. The package root exports four storage symbols: `ScopeLayer`, `ScopedLayers`, `NamedEntries`, and `AnonymousEntries`. `EntryValues` remains internal, and `store.ts` is not a package subpath.
|
||||
|
||||
`ScopeLayer` keeps the aggregate concept explicit while requiring only whole-layer emptiness. A service defines one concrete layer whose tables and domain helpers fit that service; `ScopedLayers` owns construction, selection, lifecycle attachment, notification, and aggregate reclamation.
|
||||
|
||||
## Public interface
|
||||
|
||||
```ts ignore-check
|
||||
export interface ScopeLayer {
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
constructor(
|
||||
createLayer: (scope: ScopeKey | undefined) => L,
|
||||
onChange: () => void,
|
||||
)
|
||||
|
||||
readonly global: L
|
||||
peek(scope: ScopeKey | undefined): L | undefined
|
||||
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V>
|
||||
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void
|
||||
}
|
||||
|
||||
export class NamedEntries<V> {
|
||||
constructor(duplicateError: (name: string) => Error)
|
||||
insert(name: string, value: V): () => void
|
||||
get(name: string): V | undefined
|
||||
has(name: string): boolean
|
||||
keys(): IterableIterator<string>
|
||||
entries(): IterableIterator<[string, V]>
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class AnonymousEntries<V> {
|
||||
append(value: V): () => void
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Storage contract
|
||||
|
||||
- The constructor creates `global` once with `createLayer(undefined)`. A scoped layer is created only by `effect()`; `peek()` and `merge()` never create one, and `peek(undefined)` returns `undefined` because the global layer is already explicit.
|
||||
- `merge()` is the only materialized generic read. It copies named global entries in insertion order, then applies matching scoped entries in their insertion order so same-name entries shadow without moving unrelated names.
|
||||
- `NamedEntries.insert()` checks and inserts atomically, returns an idempotent exact-entry undo, and obtains the registry's exact duplicate diagnostic from the caller-supplied factory. Lookup and iterators retain native `Map` order and stay live within one nonempty table generation; draining the table starts a new generation so an in-flight iterator cannot observe a self-replacement.
|
||||
- `AnonymousEntries.append()` assigns a unique internal key per registration, so equal callbacks or values remain independent. Its iterator is insertion-ordered and uses the same live-generation boundary.
|
||||
- `effect()` derives the key with `scopeOf(ctx)` and attaches the action to that same `ctx.effect()`. It accepts one synchronous action returning one synchronous undo; actions must either return their undo or throw before retaining a contribution. The helper does not normalize the wider Cordis `Effect` union.
|
||||
- `effect()` collects the action's undo before calling `onChange` and returns the exact `ctx.effect()` disposer. Disposal runs the action undo before notification, is idempotent through Cordis, and removes a scoped layer only after its complete `ScopeLayer.isEmpty()` becomes true.
|
||||
- `options.notify` defaults to `true`. The callback's own policy stays authoritative: tool and prompt change callbacks may throw and trigger registration rollback; `CommandService.notifyChange()` contains observer failures; tool guards pass `notify: false`.
|
||||
|
||||
## Registry migrations
|
||||
|
||||
`dsh-tools` defines one `ToolLayer` containing named tools plus anonymous compiled restrictions and guard registrations. `ToolRegistry` retains its private domain resolver for visible definitions, pre-restriction known names, restrictable global names, scoped shadowing, restrictions, and reserved `run_code` insertion. Guard evaluation live-iterates global then scoped registrations: additions to a nonempty generation can run in the current dispatch, while a self-replacement after draining the guard table begins with the next dispatch.
|
||||
|
||||
`dsh-system-prompt` defines one `PromptLayer` containing named sections and variables plus anonymous tool providers. Assembly merges sections before evaluating them, so a shadowed provider is never called. Tool-provider membership is materialized once per assembly. Variable providers live-iterate global then scoped tables: additions to a nonempty generation can run in the current assembly, while a self-replacement after draining the variable table begins with the next assembly.
|
||||
|
||||
`dsh-commands` defines a one-table layer containing `NamedEntries<RegisteredCommand>`. Effective views use `merge()`, while `CommandService` retains definition normalization and freezing, exact duplicate diagnostics, sorted immutable descriptors, direct execution, HMR cleanup, and independently contained `commands/change` observers.
|
||||
|
||||
All seven facades keep validation and diagnostics in their owning registry and continue to return the exact Cordis disposer. The migration changes neither public registry behavior nor model-, human-, wire-, persistence-, or configuration-visible output.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the independent implementations.** This avoids a new library interface but leaves lifecycle ordering, disposer identity, and scope reclamation duplicated across seven facades.
|
||||
|
||||
**One helper per table.** This removes some local code but preserves multiple per-scope maps and cannot reclaim one scope's aggregate contribution correctly.
|
||||
|
||||
**Per-scope registry instances.** Child registries would need delegation for global-plus-scoped views, special subtraction for restrictions, and observer discovery across instances. They would move complexity rather than remove it.
|
||||
|
||||
**Explicit scope parameters on registration methods.** Separate visibility and ownership inputs make mismatched lifetimes representable, while an omitted scope silently becomes global.
|
||||
|
||||
**Accept the complete Cordis `Effect` union.** None of the seven registrations has asynchronous setup, multiple undos, or an independent settlement boundary. General normalization would duplicate Cordis lifecycle machinery without a current consumer.
|
||||
|
||||
**Expose `ScopedLayers.values()`, `ScopedLayers.keys()`, or a global-admission predicate.** Those operations encode consumer-specific live/materialized and filtering policies. Direct table iteration preserves explicit live semantics, `merge()` covers the shared named shadowing operation, and `ToolRegistry` keeps its richer private resolver.
|
||||
|
||||
**Put `values()` on `ScopeLayer` or export `EntryValues`.** A layer aggregates heterogeneous tables and has no coherent value type or iteration policy. `EntryValues` is useful only to share implementation details between the two table classes; making it public would enlarge the interface without giving callers a meaningful layer-wide read.
|
||||
|
||||
**Generate layers from a mapped-type table description.** Three-table and one-table concrete layers are short, inspectable, and free to hold domain helpers. A class generator would add a second construction model and generated runtime shape for little leverage.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Scope-aware registries express one aggregate layer and reuse the same construction, ownership, rollback, notification, and reclamation choreography. Domain-specific validation, diagnostics, filtering, evaluation, and observer policy remain in each registry.
|
||||
- The public read surface stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` contract.
|
||||
- The helper is deliberately synchronous. A future registration that needs asynchronous setup or several independently owned undos must identify its ownership and settlement boundaries before widening this contract.
|
||||
- An action must throw before retaining a contribution or return an undo for everything it retained; the helper cannot repair mutation outside that contract. The provided entry operations are atomic, and migrated registries perform fallible validation before insertion.
|
||||
- A scoped layer remains allocated until every table in its aggregate is empty. Disposing one facade therefore cannot discard sibling contributions owned by the same scope.
|
||||
- The four public symbols become a reusable package contract. Keeping `EntryValues` internal and consumer policy outside the helper limits the compatibility surface.
|
||||
- The migration changes no public registry behavior and no model-, human-, wire-, persistence-, configuration-, or dependency-graph output.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dsh-scope` unit tests cover global construction, lazy scoped construction, non-creating reads, named merge order and shadowing, aggregate reclamation, factory and action failure cleanup, notification ordering and rollback, `notify: false`, effect labels, exact disposer identity, idempotent teardown, caller-owned duplicate errors, independent anonymous duplicates, live iterators, and drained-generation detachment.
|
||||
- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy and self-replacement, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy and self-replacement, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal.
|
||||
- The scoped core-data type-equivalence check ties `ScopeLayer` documentation to its source declaration. Repository documentation, module-graph, build, hygiene, coverage, and built-artifact gates exercise the root export and package boundary.
|
||||
- Existing ACP, headless, and TUI keyless snapshots remain the regression boundary for tool schemas, prompt assembly, and human commands. The implementation does not update any expected transcript.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Agent Note: 共享作用域分层存储
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-12-scoped-layers-store.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandService.register`(位于 `dsh-commands`)。
|
||||
|
||||
如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。
|
||||
|
||||
重复代码承载着三项不明显的要求:
|
||||
|
||||
- 可见性与属主必须来自同一个上下文;若分开接受二者,就能登记出对一个 scope 可见、却随另一个 scope 销毁的贡献。
|
||||
- change 回调运行前必须收集 undo,抛错的回调才能回滚变更。
|
||||
- 公开 disposer 必须就是 `ctx.effect()` 返回的那个函数;包装它会破坏 Cordis 基于身份的有序拆除。
|
||||
|
||||
共享的是生命周期与保持插入顺序的存储,而不是注册表策略。工具限制、保留传输处理、提示词求值时机、命令规范化、精确诊断和回调异常隔离,仍分别属于不同的领域契约。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-scope` 提供与键类型无关的 `store.ts` 实现模块。该包(package)继续将 Cordis 和 `@deepseek-ai/dsh-invariants` 列为对等依赖(peer dependency),其不变量配套模块保持不变。包根导出四个存储符号:`ScopeLayer`、`ScopedLayers`、`NamedEntries` 和 `AnonymousEntries`。`EntryValues` 仍是内部接口,`store.ts` 不是包子路径。
|
||||
|
||||
`ScopeLayer` 保留显式的聚合概念,同时只要求判断整个层是否为空。服务定义一个具体层,使其表结构与领域 helper 适合该服务;`ScopedLayers` 负责构造、选择、生命周期挂接、通知和聚合回收。
|
||||
|
||||
## 公开接口
|
||||
|
||||
```ts ignore-check
|
||||
export interface ScopeLayer {
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
constructor(
|
||||
createLayer: (scope: ScopeKey | undefined) => L,
|
||||
onChange: () => void,
|
||||
)
|
||||
|
||||
readonly global: L
|
||||
peek(scope: ScopeKey | undefined): L | undefined
|
||||
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V>
|
||||
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void
|
||||
}
|
||||
|
||||
export class NamedEntries<V> {
|
||||
constructor(duplicateError: (name: string) => Error)
|
||||
insert(name: string, value: V): () => void
|
||||
get(name: string): V | undefined
|
||||
has(name: string): boolean
|
||||
keys(): IterableIterator<string>
|
||||
entries(): IterableIterator<[string, V]>
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
export class AnonymousEntries<V> {
|
||||
append(value: V): () => void
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
## 存储契约
|
||||
|
||||
- 构造器只创建一次 `global`,调用的是 `createLayer(undefined)`。只有 `effect()` 会创建专属层;`peek()` 和 `merge()` 从不创建专属层,而 `peek(undefined)` 返回 `undefined`,因为全局层已经显式存在。
|
||||
- `merge()` 是唯一会物化结果的通用读取接口。它按插入顺序复制全局命名条目,再按专属条目的插入顺序应用这些条目;同名条目完成遮蔽,但不会移动无关名称。
|
||||
- `NamedEntries.insert()` 以原子方式检查并插入,返回幂等且只撤销该精确条目的 undo,并通过调用方提供的工厂取得所属注册表的精确重名诊断。查询与迭代器保留 `Map` 的原生顺序,并在同一个非空表 generation 内保持活遍历;清空表会开启新的 generation,因此尚未结束的迭代器无法观察到自我替换。
|
||||
- `AnonymousEntries.append()` 为每次登记分配唯一内部键,因此值相等的回调或其他值仍彼此独立。其迭代器保留插入顺序,并采用同样的 generation 活遍历边界。
|
||||
- `effect()` 通过 `scopeOf(ctx)` 导出键,并把 action 挂到同一个 `ctx.effect()` 上。它只接受一个同步 action,且该 action 只返回一个同步 undo;action 要么返回其 undo,要么必须在保留任何贡献之前抛错。helper 不会规范化更宽泛的 Cordis `Effect` union。
|
||||
- `effect()` 在调用 `onChange` 前收集 action 的 undo,并原样返回 `ctx.effect()` 的 disposer。销毁时先运行 action undo 再通知;Cordis 保证其幂等性;只有整个层的 `ScopeLayer.isEmpty()` 变为 true 后,helper 才删除专属层。
|
||||
- `options.notify` 默认为 `true`。回调自身的策略仍具最终效力:工具与提示词的 change 回调可以抛错并触发登记回滚;`CommandService.notifyChange()` 会隔离观察者失败;工具 guard 传入 `notify: false`。
|
||||
|
||||
## 注册表迁移
|
||||
|
||||
`dsh-tools` 定义一个 `ToolLayer`,其中包含命名工具以及匿名的已编译 restriction 和 guard 登记。`ToolRegistry` 保留其私有领域解析器,由它处理可见定义、限制前的已知名称、可限制的全局名称、专属遮蔽、restriction,以及保留的 `run_code` 插入。guard 求值会先活遍历全局登记,再活遍历专属登记:向非空 generation 新增的登记可以在当前分发中运行,而 guard 表清空后的自我替换则从下一次分发开始运行。
|
||||
|
||||
`dsh-system-prompt` 定义一个 `PromptLayer`,其中包含命名的段落与变量,以及匿名工具提供方。组装流程在求值前合并段落,因此被遮蔽的提供方不会被调用。每次组装只物化一次工具提供方成员集合。变量提供方会先活遍历全局表,再活遍历专属表:向非空 generation 新增的提供方可以在当前组装中运行,而变量表清空后的自我替换则从下一次组装开始运行。
|
||||
|
||||
`dsh-commands` 定义一个单表层,其中包含 `NamedEntries<RegisteredCommand>`。生效视图使用 `merge()`;`CommandService` 则保留对定义的规范化与冻结处理、精确重名诊断、经过排序的不可变描述符、直接执行、HMR(热模块替换)清理,以及对各个 `commands/change` 观察者分别隔离失败的行为。
|
||||
|
||||
七个门面都把校验与诊断留在所属注册表中,并继续返回 Cordis 的原始 disposer。迁移既不改变公开注册表行为,也不改变模型可见或人类可见的输出,以及协议、持久化或配置层面的可见输出。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**保留彼此独立的实现。** 这样不必新增库接口,但七个门面仍会重复生命周期顺序、disposer 身份和 scope 回收。
|
||||
|
||||
**每张表一个 helper。** 这能减少一部分局部代码,但会保留多张按 scope 划分的映射,而且无法正确回收某个 scope 的聚合贡献。
|
||||
|
||||
**每 scope 一个注册表实例。** 子注册表需要通过委托获得全局加专属的视图,对 restriction 进行特殊的减法处理,并跨实例发现观察者。这只会转移复杂度,而不会消除复杂度。
|
||||
|
||||
**注册方法上的显式 scope 参数。** 分开的可见性与属主输入让不匹配的生命周期成为可表达状态,而遗漏 scope 则会静默变成全局登记。
|
||||
|
||||
**接受完整的 Cordis `Effect` union。** 七个登记口都没有异步 setup、多份 undo 或独立 settlement 边界。通用规范化会在没有现有消费者需要它时重复 Cordis 的生命周期 machinery。
|
||||
|
||||
**暴露 `ScopedLayers.values()`、`ScopedLayers.keys()` 或全局放行谓词。** 这些操作会编码消费方特有的活遍历或物化策略,以及过滤策略。直接遍历条目表可保留显式的活语义,`merge()` 覆盖共享的命名遮蔽操作,而 `ToolRegistry` 继续保有功能更丰富的私有解析器。
|
||||
|
||||
**把 `values()` 放在 `ScopeLayer` 上,或导出 `EntryValues`。** 一个层会聚合异构表,因而没有一致的值类型或迭代策略。`EntryValues` 只适合在两个表类之间共享实现细节;将其公开只会扩大接口,却不能为调用方提供有意义的整层读取方式。
|
||||
|
||||
**通过 mapped-type 表描述生成层。** 三表与单表具体层都很短、易于检查,并可自由持有领域 helper。类生成器会增加第二种构造模型和生成式运行时形状,收益却很小。
|
||||
|
||||
## 后果
|
||||
|
||||
- 支持作用域的注册表各自通过一个聚合层表达状态,并复用相同的构造、属主、回滚、通知和回收编排。各注册表仍各自保有领域特有的校验、诊断、过滤、求值和观察者策略。
|
||||
- 公开读取接口保持狭窄:直接遍历条目表可保留显式的活语义,`merge()` 是唯一共享的物化遮蔽操作。异构的 `ScopeLayer` 不具备整层 `values()` 契约。
|
||||
- helper 刻意保持同步。未来的登记若需要异步 setup 或多份分别拥有属主的 undo,必须先明确属主与 settlement 边界,再拓宽这项契约。
|
||||
- action 必须在保留贡献前抛错,或者为自己保留的一切返回 undo;helper 无法修复超出这项契约的变更。提供的条目操作是原子的,迁移后的注册表会在插入前执行可能失败的校验。
|
||||
- 专属层会一直保持已分配状态,直到其聚合内的所有表都为空。因此,销毁一个门面不会丢弃同一 scope 拥有的其他贡献。
|
||||
- 四个公开符号构成一项可复用的包契约。将 `EntryValues` 保持为内部接口,并把消费方策略留在 helper 之外,可以限制兼容性范围。
|
||||
- 迁移不改变任何公开注册表行为,也不改变模型、人类、协议、持久化、配置或依赖图层面的任何输出。
|
||||
|
||||
## 验证
|
||||
|
||||
- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。
|
||||
- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。
|
||||
- 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。
|
||||
- 现有 ACP(Agent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema、提示词组装和人类命令的回归边界。实现不会更新任何预期 transcript(文本记录)。
|
||||
@@ -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
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: 09d30594fe31eed138a128dabc1947b15857808d
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: 131531d9dba7cb01407191bf937f8b0ee3c6860a
|
||||
2026-07-19-zstandard-jsonl-session-logs.md: ccfc81dd47504e6a9e9b19cda7c4b9fc40accecc
|
||||
2026-07-19-zstandard-jsonl-session-logs.zh.md: de5436a6eaefcb45e52e0ff4fea8592c7efcd127
|
||||
|
||||
@@ -24,7 +24,7 @@ The compressed artifact is a standard concatenation of independent [Zstandard fr
|
||||
|
||||
Compression uses Node's built-in [`zstdCompress` and `zstdDecompress`](https://nodejs.org/download/release/v22.19.0/docs/api/zlib.html), available at the repository's Node 22.19 floor. The backend enables `ZSTD_c_checksumFlag`, otherwise accepts Node's defaults, and exposes neither a compression-level knob nor a new dependency. The API is marked experimental by Node, so the Node 22.19, 24, and 26 compatibility gate exercises the exact helper.
|
||||
|
||||
First materialization compresses the two initial frames before opening the temporary file, then keeps the existing write, file `fsync`, collision-safe hard-link publication, and directory `fsync` sequence. Later batches are compressed before opening the destination and appended at EOF. A caught write or file-sync failure truncates to the prior byte length, syncs the rollback, and rethrows so the coordinator can retry the unchanged batch.
|
||||
First materialization compresses the two initial frames before opening the temporary file, then writes and `fsync`s that file. POSIX publishes it through a collision-safe hard link and directory `fsync`; Windows publishes it without replacement through `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)`. Later batches are compressed before opening the destination and appended at EOF. A caught write or file-sync failure closes the append handle, reopens the log read/write, truncates to the prior byte length, syncs the rollback, and rethrows so the coordinator can retry the unchanged batch on both platforms.
|
||||
|
||||
### Read, listing, and crash recovery
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
|
||||
|
||||
压缩使用 Node 内置的 [`zstdCompress` 与 `zstdDecompress`](https://nodejs.org/download/release/v22.19.0/docs/api/zlib.html),仓库最低支持的 Node 22.19 已提供这些 API。后端启用 `ZSTD_c_checksumFlag`,其余采用 Node 默认值,不公开压缩级别调节项,也不增加依赖。Node 将该 API 标记为实验性,因此 Node 22.19、24 与 26 兼容性门禁会执行同一个辅助实现。
|
||||
|
||||
首次物化会在打开临时文件之前压缩两个初始帧,然后保留既有的写入、文件 `fsync`、避免冲突的硬链接发布与目录 `fsync` 顺序。后续批次也会先压缩,再打开目标并在 EOF 追加。捕获到写入或文件同步失败时,后端会截断到原有字节长度,同步回滚结果,再重新抛出错误,让协调器重试未变化的批次。
|
||||
首次物化会在打开临时文件之前压缩两个初始帧,然后写入该文件并执行 `fsync`。POSIX 通过避免冲突的硬链接和目录 `fsync` 发布该文件;Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 在不替换目标文件的情况下发布。后续批次也会先压缩,再打开目标并在 EOF 追加。捕获到写入或文件同步失败时,后端会关闭追加句柄,以读写方式重新打开日志,截断到原有字节长度,同步回滚结果,再重新抛出错误,让协调器能够在两个平台上重试未变化的批次。
|
||||
|
||||
### 读取、列举与崩溃恢复
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.md: 013119508da9be426c417797cf7a0ec14e276814
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: 8ae82884c3b80409d07d3bbcfc8c273e8b227dc8
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Preserve Windows DACLs during atomic file replacement
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL.
|
||||
|
||||
Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Rely on directory inheritance for replacements.** Rejected because a target may carry a narrower explicit or protected DACL than its parent, so inheritance neither protects staged content nor preserves the target access policy.
|
||||
|
||||
**Use `ReplaceFileW` without protecting the temp.** Rejected because it repairs the final descriptor only after the content has already been written under the staging file's inherited DACL.
|
||||
|
||||
**Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one.
|
||||
|
||||
## Consequences
|
||||
|
||||
Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Windows 原子文件替换期间保留 DACL
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-19-windows-atomic-write-dacl-preservation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。
|
||||
|
||||
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**替换文件时依赖目录继承。** 不予采用,因为目标文件可能带有比父目录更严格的显式或受保护 DACL;目录继承既无法保护暂存内容,也无法保留目标文件的访问策略。
|
||||
|
||||
**使用 `ReplaceFileW`,但不保护临时文件。** 不予采用,因为这只能在内容已经按暂存文件继承的 DACL 写入之后修复最终描述符。
|
||||
|
||||
**每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。
|
||||
|
||||
## 影响
|
||||
|
||||
替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变。
|
||||
@@ -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
|
||||
2026-07-14-cross-family-fs-sandbox.md: 9b6312e5994469606bd1645902fc798f70258580
|
||||
2026-07-14-cross-family-fs-sandbox.zh.md: d4816e03d94bdf12b2db875d71dccb7db3a2c0d7
|
||||
2026-07-14-cross-family-fs-sandbox.md: 0897695cc14b7573ebb53f3ffa6a460652882b37
|
||||
2026-07-14-cross-family-fs-sandbox.zh.md: 15de061a0d2b18392f839c927e9b0f5d0cacf28b
|
||||
|
||||
@@ -31,7 +31,7 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching
|
||||
`packages/fs/fs-sandbox/` (`@deepseek-ai/dsh-fs-sandbox`) mirrors the `bash-local`/`bash-sandbox` split: `SandboxedFileSystem extends LocalFileSystem`, registered as `ctx.fs`, injecting `sandboxPolicy`. Reads (`resolve`/`stat`/`readText`/`streamText`/`listDir`) pass through untouched — every mode permits reading. The two mutations enforce by mode before delegating to the inherited atomic write:
|
||||
|
||||
- `read-only` denies `writeText`/`editText` outright.
|
||||
- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Containment is prefix-inclusion on real paths; the target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `workspace-write` fences the canonicalized target against the writable-root set — `writableRoots(policy)` in `dsh-sandbox`: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), each realpathed — the SAME set the Seatbelt profile grants, so the fs fence is the fourth dialect of one mode meaning alongside the bwrap/Landlock/Seatbelt profiles, and "the write tool cannot write `/tmp` but bash can" asymmetries cannot arise. Canonical spellings take a lexical containment fast path; when Windows exposes one directory through different casing or long-name/8.3 spellings, an ancestor walk compares filesystem identity rather than weakening the boundary to textual prefix guesses. The target is re-canonicalized (`resolve` realpaths the deepest existing ancestor) immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `danger-full-access` delegates unfenced.
|
||||
|
||||
A denial is the structured `FS_SANDBOX_DENIED` carrying the effective mode — distinct from `FS_PERMISSION_DENIED` (a host EACCES is the world refusing; this is policy refusing). No text inference: an in-process fence knows exactly what it denied. The per-call carrier is a trailing optional `sandboxMode` on `writeText`/`editText` (the filesystem twin of `BashExecRequest.sandboxMode`); the seam stays session-free (the caller stamps, exactly as `resolve` takes a cwd), and the bare local backend carries-and-ignores it. `FileSystem.sandboxMode` is the capability fact (`undefined` on the base and `fs-local`, the default on `SandboxedFileSystem`), so the tool layer advertises escalation from composition truth.
|
||||
@@ -74,7 +74,7 @@ The sandbox Agent Note's original cross-family sketch put fs enforcement on the
|
||||
What shipped — the tiers in § Testing hold each:
|
||||
|
||||
- Under `read-only`, `write`/`edit` return the `[sandbox: file access denied under read-only mode]` marker and the disk is untouched; `read`/`listDir` behave identically to `dsh-fs-local`.
|
||||
- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, and a new file created under such a symlink — denies every escape on real disks.
|
||||
- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks.
|
||||
- A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing.
|
||||
- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold.
|
||||
- A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default.
|
||||
@@ -90,5 +90,5 @@ Costs and accepted limits:
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, root-ending-in-separator) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit.
|
||||
- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins the default accessors, the fold/setter, the load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-mode fence and the containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, and alias-equivalent spelling) on a real filesystem, plus the per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, the mode stamp, the fold, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission` migrate to the relocated policy/kit.
|
||||
- Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once.
|
||||
|
||||
@@ -31,7 +31,7 @@ Status: implemented
|
||||
`packages/fs/fs-sandbox/`(`@deepseek-ai/dsh-fs-sandbox`)镜像 `bash-local`/`bash-sandbox` 的拆分:`SandboxedFileSystem extends LocalFileSystem`,注册为 `ctx.fs`,注入 `sandboxPolicy`。读取(`resolve`/`stat`/`readText`/`streamText`/`listDir`)原样透传——每种模式都允许读。两个变更操作在委托给继承来的原子写之前按模式执行:
|
||||
|
||||
- `read-only` 直接拒绝 `writeText`/`editText`。
|
||||
- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。包含判定是对真实路径的前缀包含;目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。
|
||||
- `workspace-write` 把规范化后的目标围栏于可写根集合——`dsh-sandbox` 中的 `writableRoots(policy)`:工作区根加上平台临时目录(`/tmp`、`os.tmpdir()`),各自 realpath——与 Seatbelt profile 授予的是同一个集合,所以 fs 围栏是这一个模式含义在 bwrap/Landlock/Seatbelt profile 之外的第四种方言,因此不会出现「write 工具不能写 `/tmp` 而 bash 能」的不对称。规范化路径写法采用词法包含的快速路径;当 Windows 以大小写不同的路径、长文件名或 8.3 短文件名表示同一目录时,系统会逐级遍历祖先目录并比较文件系统身份,而不会把边界弱化为依据文本前缀猜测包含关系。目标在委托前被立即重新规范化(`resolve` 对最深的既有祖先做 realpath),因此自工具解析该目标以来被换出的祖先符号链接会被捕获。
|
||||
- `danger-full-access` 不加围栏地委托。
|
||||
|
||||
拒绝是结构化的 `FS_SANDBOX_DENIED`,携带生效模式——区别于 `FS_PERMISSION_DENIED`(宿主 EACCES 是世界在拒绝;这里是策略在拒绝)。无文本推断:进程内围栏确切知道它拒绝了什么。per-call 载体是 `writeText`/`editText` 上一个末尾可选的 `sandboxMode`(文件系统侧对应 `BashExecRequest.sandboxMode`);该 seam 保持无会话依赖(由调用方盖章,正如 `resolve` 接收一个 cwd),而裸的本地后端携带并忽略它。`FileSystem.sandboxMode` 是能力事实(在基类与 `fs-local` 上为 `undefined`,在 `SandboxedFileSystem` 上为默认值),所以工具层按组合真相来宣告升级。
|
||||
@@ -74,7 +74,7 @@ Status: implemented
|
||||
已交付的部分——§ Testing 的各层各自钉住:
|
||||
|
||||
- 在 `read-only` 下,`write`/`edit` 返回 `[sandbox: file access denied under read-only mode]` 标记,磁盘不受触动;`read`/`listDir` 与 `dsh-fs-local` 行为一致。
|
||||
- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录,以及在这样一个符号链接下新建的文件——在真实磁盘上拒绝每一种逃逸。
|
||||
- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。
|
||||
- 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。
|
||||
- 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。
|
||||
- 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。
|
||||
@@ -90,5 +90,5 @@ Status: implemented
|
||||
|
||||
## Testing
|
||||
|
||||
- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、以分隔符结尾的根),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。
|
||||
- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住默认访问器、折叠/setter、加载期模式拒绝,以及 HMR 安全。`dsh-fs-sandbox` 在真实文件系统上钉住 per-mode 围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、模式盖章、折叠、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission` 迁移到迁移后的策略/工具集。
|
||||
- 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。
|
||||
|
||||
@@ -56,6 +56,8 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
|
||||
|
||||
The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal.
|
||||
|
||||
The DeepSeek native `web_search` probe is registered but skipped. The live Anthropic-compatible endpoint can return a successful response without structured source blocks, so its positive-source assertion is not a reliable merge signal; unit coverage still pins response parsing, but CI does not prove the live source-block wire shape.
|
||||
|
||||
## Security
|
||||
|
||||
The repository's first CI secret requires a recorded threat model because access differs between same-repository, fork, and Dependabot pull requests and changes when the repository becomes public.
|
||||
|
||||
@@ -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
|
||||
2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a
|
||||
2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Keep supported-platform tests semantic
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-22-cross-platform-test-fixtures.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and child-pipe closure or event-loop scheduling does not settle at the same point on every host. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture.
|
||||
|
||||
Treating fixture syntax as product behavior either reports false regressions or encourages production normalization that erases native path semantics.
|
||||
|
||||
## Decision
|
||||
|
||||
Tests of platform-neutral behavior construct absolute paths and `file:` URIs with the host's `node:path` and `node:url` APIs, then assert native absolute output or stable workspace-relative output as the contract requires. Invalid-URI fixtures use encodings rejected by `fileURLToPath()` on every supported platform.
|
||||
|
||||
Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles.
|
||||
|
||||
Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient.
|
||||
|
||||
Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Normalize all paths and URIs to POSIX strings.** This would make assertions uniform but would change correct Windows behavior: external paths are native absolute paths, UNC file URIs are valid, and configured homes resolve through the host path rules.
|
||||
|
||||
**Manipulate child-pipe internals until a write fails.** CRT descriptors and libuv handles have different ownership across hosts and Node versions, so this would test undocumented fixture machinery instead of the connection's write-failure contract.
|
||||
|
||||
**Skip whole files or packages on Windows.** Broad exclusions would hide supported behavior. Only the individual fixture whose state cannot exist on Windows is excluded; the surrounding contract remains covered.
|
||||
|
||||
## Consequences
|
||||
|
||||
Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 让受支持平台的测试聚焦语义
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-22-cross-platform-test-fixtures.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;子进程管道关闭或事件循环调度在不同宿主上的稳定时点也不一致。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。
|
||||
|
||||
把 fixture 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。
|
||||
|
||||
## 决策
|
||||
|
||||
测试平台无关行为时,使用宿主的 `node:path` 和 `node:url` API 构造绝对路径与 `file:` URI,再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。
|
||||
|
||||
传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。
|
||||
|
||||
语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。
|
||||
|
||||
对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为:外部路径是原生绝对路径,UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。
|
||||
|
||||
**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败契约。
|
||||
|
||||
**在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture;相关契约仍保持覆盖。
|
||||
|
||||
## 后果
|
||||
|
||||
可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,dispose 的调用方仍能观察到该失败。
|
||||
3
.github/AGENTS.md
vendored
Normal file
3
.github/AGENTS.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# AGENTS.md — GitHub Actions
|
||||
|
||||
Run Windows jobs under native `pwsh`.
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -642,6 +642,7 @@ jobs:
|
||||
env:
|
||||
# Keep ESLint itself single-threaded: 16 ESLint workers took 174 seconds on
|
||||
# this image. The outer scheduler still overlaps lint with the other gates.
|
||||
DSH_COVERAGE_MAX_WORKERS: '4'
|
||||
DSH_ESLINT_CACHE: '1'
|
||||
DSH_GATE_CONCURRENCY: '32'
|
||||
DSH_PUBLINT_CONCURRENCY: '32'
|
||||
|
||||
@@ -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
|
||||
architecture.md: 517288c4480295b190050651b6047c608d16cb4c
|
||||
architecture.zh.md: 70a5783d462f3b2d71fc47c0f7768f62141e5cad
|
||||
architecture.md: b3e2db14727c299562f9b061459547d147ec1d70
|
||||
architecture.zh.md: 6fd2a7e161a10ac5f2dcee6859b0d6251671676f
|
||||
|
||||
@@ -14,7 +14,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv
|
||||
|
||||
| ctx key | Package | Role |
|
||||
|---|---|---|
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration primitive (library) |
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registration and shared layer storage (library) |
|
||||
| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions |
|
||||
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
|
||||
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
|
||||
@@ -133,7 +133,7 @@ Every session event is turn-enclosed. Reloading preserves an interrupted tail an
|
||||
|
||||
### Agent Scope
|
||||
|
||||
Each agent owns a scoped `agent.ctx`; registrations shadow globals, filter dispatch, and unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
Each agent owns a scoped `agent.ctx`; shared storage overlays global tool, prompt, and command entries while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch, and every scoped contribution unwinds with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, while turn, step, signal, cwd, and authority remain explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
|
||||
## State
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
| ctx 键 | 包 | 职责 |
|
||||
|---|---|---|
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册原语(库) |
|
||||
| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册与共享层存储(库) |
|
||||
| `ctx.sessions` | `dsh-session` | 内存中的事件溯源会话 |
|
||||
| `ctx.systemPrompt` | `dsh-system-prompt` | 有序提示词片段、工具 schema 和提示词变量 |
|
||||
| `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) |
|
||||
@@ -133,7 +133,7 @@ forever:
|
||||
|
||||
### Agent 作用域
|
||||
|
||||
每个 agent 都拥有一个作用域化的 `agent.ctx`;注册项会遮蔽全局项、过滤分派,并在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
|
||||
每个 agent 都拥有一个作用域化的 `agent.ctx`;共享存储会在全局工具、提示词和命令条目之上叠加作用域条目,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派,每项作用域贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。参见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,而轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
|
||||
|
||||
## 状态
|
||||
|
||||
|
||||
@@ -892,7 +892,7 @@ export interface Config {
|
||||
export type JsonlCompression = 'zstd' | 'none'
|
||||
```
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:36`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:37`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-sqlite`
|
||||
|
||||
@@ -1095,7 +1095,7 @@ export interface Config {
|
||||
* before the parent escalates to a signal.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
|
||||
/** Termination confirmation window (ms), including forced exit on every platform. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
@@ -1493,7 +1493,7 @@ export interface TuiConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tui/src/index.ts:128`](../packages/ui/tui/src/index.ts)
|
||||
Source: [`packages/ui/tui/src/index.ts:129`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui-demo`
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ A command was registered or unregistered. This is an unfiltered registry notific
|
||||
'commands/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:83`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:103`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<Comma
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md) · [CommandResult](../core-data-structures/commands.md)
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:207`](../../packages/ui/commands/src/index.ts)
|
||||
Source: [`packages/ui/commands/src/index.ts:227`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `ctx.compact` — `CompactService` (abstract seam)
|
||||
|
||||
@@ -1213,7 +1213,7 @@ async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
|
||||
Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md)
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:213`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `ctx.tasks` — `TaskService`
|
||||
|
||||
@@ -1456,7 +1456,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:524`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Scoped Registration
|
||||
|
||||
The [scope package](../../packages/core/scope) supplies the identity and carrier vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the implementation rationale, while the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics.
|
||||
The [scope package](../../packages/core/scope) supplies the identity, carrier, and scoped-layer vocabulary that makes one registration context mean both per-agent visibility and shared lifetime ownership. It is a library primitive rather than a Cordis service; the [agent-scope runtime-design Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#scope-routing-one-opaque-key-selects-one-layer) owns the lifecycle rationale, the [shared-storage Agent Note](../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md) owns the registry-layer decision, and the package [README](../../packages/core/scope/README.md) owns the callable API and filtering semantics.
|
||||
|
||||
Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts).
|
||||
Sources: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index.ts) and [`packages/core/scope/src/store.ts`](../../packages/core/scope/src/store.ts).
|
||||
|
||||
## Identity and dispatch carrier
|
||||
|
||||
@@ -39,3 +39,19 @@ interface Scope {
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
## Scoped registry layer
|
||||
|
||||
`ScopeLayer` represents one registry's complete contribution at the global or exact-scope level. A concrete layer may aggregate multiple named and anonymous tables; whole-layer emptiness lets `ScopedLayers` reclaim scoped state without discarding a sibling table.
|
||||
|
||||
```ts type-equiv
|
||||
/** One scope's aggregate contribution to a registry. */
|
||||
interface ScopeLayer {
|
||||
/** Whether every table in this layer is empty. */
|
||||
isEmpty(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`ScopedLayers<L>` owns the eager global layer and lazily created exact-scope layers. Reads do not create layers: `peek(undefined)` means no overlay, while `merge()` materializes insertion-ordered global named entries followed by scoped shadows. Registrations use one context for both visibility and Cordis effect ownership, collect one synchronous undo before optional notification, return Cordis's exact disposer, and reclaim a scoped layer only when its complete `ScopeLayer` is empty.
|
||||
|
||||
`NamedEntries<V>` supplies insertion-ordered lookup and live iteration with caller-owned duplicate errors. `AnonymousEntries<V>` gives every append a unique identity so equal values remain independent. Iteration stays live within one nonempty table generation; draining the table detaches existing iterators from later insertions. Both return idempotent exact-entry undos; the shared `EntryValues` implementation interface is not public.
|
||||
|
||||
@@ -25,7 +25,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
|
||||
@@ -71,7 +71,12 @@ const SCENARIOS: Scenario[] = [
|
||||
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
|
||||
{ name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
|
||||
{ name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
|
||||
{ name: 'workspace-edit', hasModelTurn: true, recorded: true },
|
||||
{
|
||||
name: 'workspace-edit',
|
||||
hasModelTurn: true,
|
||||
recorded: true,
|
||||
pinsNativeWindowsStdout: true,
|
||||
},
|
||||
{ name: 'fs-read', hasModelTurn: true, recorded: true },
|
||||
{ name: 'fs-write', hasModelTurn: true, recorded: true },
|
||||
{ name: 'fs-edit', hasModelTurn: true, recorded: true },
|
||||
@@ -110,7 +115,9 @@ const SCENARIOS: Scenario[] = [
|
||||
configPath: WORKSPACE_CONTEXT_CONFIG,
|
||||
},
|
||||
{ name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
|
||||
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true },
|
||||
// Cancelling a live bash call relies on POSIX process-group termination;
|
||||
// Windows bash process-tree kill is deferred with the Bash execution domain.
|
||||
{ name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
|
||||
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-multi', hasModelTurn: true, recorded: true },
|
||||
{ name: 'subagent-fork', hasModelTurn: true, recorded: true },
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"A file named greeting.txt in","updatedAt":"{{updatedAt}}"}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Read"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" greeting"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Append"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" WORLD"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Read"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cat"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" confirm"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Reply"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}\\greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" append"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"WOR"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LD"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cat"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" confirm"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","title":"printf '\\nWORLD' >> greeting.txt","kind":"execute","status":"in_progress","rawInput":"printf '\\nWORLD' >> greeting.txt","content":[{"type":"content","content":{"type":"text","text":"Append newline and WORLD to greeting.txt"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\n(no output)\n```"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Good"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" now"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" let"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cat"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","title":"cat greeting.txt","kind":"execute","status":"in_progress","rawInput":"cat greeting.txt","content":[{"type":"content","content":{"type":"text","text":"Read greeting.txt to confirm"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nhello\n\nWORLD\n```"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" now"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" hello"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" WORLD"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"I"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
@@ -109,6 +109,13 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
|
||||
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
|
||||
const observedScenarios = new Set<string>()
|
||||
|
||||
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
|
||||
const rel = relative(cwd, displayPath)
|
||||
if (rel === '') return displayCwd
|
||||
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath
|
||||
return `${displayCwd}/${rel.split(sep).join('/')}`
|
||||
}
|
||||
|
||||
function scenarioDir(scenario: Scenario): string {
|
||||
return join(SNAPSHOTS_DIR, scenario.name)
|
||||
}
|
||||
@@ -139,9 +146,10 @@ function rawSessionLog(session: Session): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshot(snapshot: string, cwd: string): string {
|
||||
function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
|
||||
return snapshot
|
||||
.split(`/private${cwd}`).join('/workspace/project')
|
||||
.split(displayCwd).join('/workspace/project')
|
||||
.split(cwd).join('/workspace/project')
|
||||
.replace(UUID_RE, '{{uuid}}')
|
||||
}
|
||||
@@ -160,9 +168,20 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
|
||||
async function mountScenarioContext(
|
||||
scenario: Scenario,
|
||||
cwd: string,
|
||||
displayCwd: string,
|
||||
fixtureFile: string,
|
||||
childFiles: string[],
|
||||
): Promise<Context> {
|
||||
class SnapshotLocalFileSystem extends LocalFileSystem {
|
||||
override async resolve(
|
||||
path: string,
|
||||
opts?: { cwd?: string; signal?: AbortSignal },
|
||||
): Promise<Awaited<ReturnType<LocalFileSystem['resolve']>>> {
|
||||
const target = await super.resolve(path, opts)
|
||||
return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) }
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentCore, {
|
||||
agents: [],
|
||||
@@ -173,7 +192,7 @@ async function mountScenarioContext(
|
||||
})
|
||||
await ctx.plugin(TokenMeterService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
@@ -213,6 +232,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
|
||||
|
||||
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
|
||||
const displayCwd = `/tmp/${basename(cwd)}`
|
||||
let ctx: Context | undefined
|
||||
let controller: ReturnType<typeof createTuiChat> | undefined
|
||||
const terminal = new HeadlessTerminal(100, 36)
|
||||
@@ -221,7 +241,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const source = join(scenarioDir(scenario), 'workspace')
|
||||
await cp(source, cwd, { recursive: true })
|
||||
}
|
||||
ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles)
|
||||
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
|
||||
const disposedSessions: Session[] = []
|
||||
ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
|
||||
const workflowEvents: string[] = []
|
||||
@@ -241,7 +261,11 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
title: 'DSH TUI snapshot',
|
||||
welcome: `Recorded replay: ${scenario.name}`,
|
||||
maxToolOutputLines: 8,
|
||||
}, { terminal, exit: () => {} })
|
||||
}, {
|
||||
terminal,
|
||||
exit: () => {},
|
||||
formatCwd: () => displayCwd,
|
||||
})
|
||||
await settleTerminal(terminal)
|
||||
|
||||
for (const prompt of prompts) {
|
||||
@@ -272,6 +296,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const snapshot = normalizeTerminalSnapshot(
|
||||
await terminal.snapshot({ includeScrollback: true }),
|
||||
cwd,
|
||||
displayCwd,
|
||||
)
|
||||
await handle.dispose()
|
||||
const children = disposedSessions
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
@@ -61,6 +61,7 @@ class RecordingFileSystem extends FileSystem {
|
||||
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
|
||||
lstatTypes = new Map<string, FsPathInfo['type']>()
|
||||
throwOnStat = new Set<string>()
|
||||
throwOnRead = new Set<string>()
|
||||
omitSizes = new Set<string>()
|
||||
readTargets: string[] = []
|
||||
readTextTargets: string[] = []
|
||||
@@ -69,7 +70,7 @@ class RecordingFileSystem extends FileSystem {
|
||||
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
||||
if (opts?.signal !== undefined) this.signals.push(opts.signal)
|
||||
opts?.signal?.throwIfAborted()
|
||||
const absolute = join(opts?.cwd ?? '/', path)
|
||||
const absolute = resolve(opts?.cwd ?? '/', path)
|
||||
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ class RecordingFileSystem extends FileSystem {
|
||||
if (signal !== undefined) this.signals.push(signal)
|
||||
signal?.throwIfAborted()
|
||||
this.readTargets.push(target.targetKey)
|
||||
if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`)
|
||||
const content = this.entries.get(target.targetKey)?.content ?? ''
|
||||
return (async function* () {
|
||||
const midpoint = Math.ceil(content.length / 2)
|
||||
@@ -299,8 +301,8 @@ describe('workspace context instruction discovery', () => {
|
||||
expect(files.map(file => file.displayPath)).toEqual([
|
||||
'$DSH_HOME/AGENTS.md',
|
||||
'AGENTS.md',
|
||||
'packages/CLAUDE.md',
|
||||
'packages/app/AGENTS.md',
|
||||
join('packages', 'CLAUDE.md'),
|
||||
join('packages', 'app', 'AGENTS.md'),
|
||||
])
|
||||
expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md'))
|
||||
} finally {
|
||||
@@ -358,22 +360,25 @@ describe('workspace context instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('skips a file that becomes unreadable after discovery without failing the request', async () => {
|
||||
it('skips a provider file whose read fails after a successful metadata probe', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const cwd = join(root, 'pkg')
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await mkdir(cwd, { recursive: true })
|
||||
const leaf = join(cwd, 'AGENTS.md')
|
||||
await write(leaf, 'secret-ish rule')
|
||||
await chmod(leaf, 0)
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' })
|
||||
fs.throwOnRead.add(leaf)
|
||||
|
||||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
|
||||
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs)
|
||||
|
||||
expect(loaded).toBeUndefined()
|
||||
await chmod(leaf, 0o600)
|
||||
expect(fs.readTargets).toEqual([leaf])
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
@@ -958,7 +963,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('omitted AGENTS.md')
|
||||
expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
|
||||
expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
@@ -1138,8 +1143,8 @@ describe('workspace context request injection', () => {
|
||||
})
|
||||
|
||||
it('keeps the direct provider API usable without an operation signal', async () => {
|
||||
const root = '/virtual/no-signal-repo'
|
||||
const home = '/virtual/no-signal-home'
|
||||
const root = resolve('/virtual/no-signal-repo')
|
||||
const home = resolve('/virtual/no-signal-home')
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
@@ -1447,7 +1452,7 @@ describe('workspace context request injection', () => {
|
||||
await composeBaselinePrefix(ctx, agent)
|
||||
|
||||
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule')
|
||||
expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule')
|
||||
expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`)
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -1751,7 +1756,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
changes: [{
|
||||
action: 'set',
|
||||
scope: 'pkg',
|
||||
path: 'pkg/AGENTS.md',
|
||||
path: join('pkg', 'AGENTS.md'),
|
||||
}],
|
||||
})
|
||||
const meta = workspaceContextOf(result)?.meta
|
||||
@@ -1765,7 +1770,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text).toBe([
|
||||
'<system-reminder>',
|
||||
'Additional instructions from: pkg/AGENTS.md',
|
||||
`Additional instructions from: ${join('pkg', 'AGENTS.md')}`,
|
||||
'',
|
||||
'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.',
|
||||
'',
|
||||
@@ -1804,7 +1809,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const text = blocksText(workspaceContextOf(result)?.content)
|
||||
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
|
||||
expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
|
||||
expect(text).toContain('local package rule')
|
||||
expect(text).not.toContain('native package rule')
|
||||
} finally {
|
||||
@@ -1985,11 +1990,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
expect(workspaceContextOf(changed)?.meta).toMatchObject({
|
||||
kind: 'workspace-instructions',
|
||||
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
|
||||
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
|
||||
'<system-reminder>',
|
||||
'Updated instructions from: pkg/AGENTS.md',
|
||||
`Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
|
||||
'',
|
||||
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
|
||||
'',
|
||||
@@ -2032,11 +2037,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
expect(workspaceContextOf(changed)?.meta).toMatchObject({
|
||||
changes: [{
|
||||
action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md',
|
||||
action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'),
|
||||
}],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`)
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`)
|
||||
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
|
||||
expect(unchanged.additionalContexts).toBeUndefined()
|
||||
} finally {
|
||||
@@ -2070,11 +2075,11 @@ describe('dynamic nested workspace context injection', () => {
|
||||
expect(workspaceContextOf(removed)?.meta).toEqual({
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }],
|
||||
changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
|
||||
'<system-reminder>',
|
||||
'Instructions removed: pkg/AGENTS.md',
|
||||
`Instructions removed: ${join('pkg', 'AGENTS.md')}`,
|
||||
'',
|
||||
'The previously loaded instructions from this file no longer apply.',
|
||||
'</system-reminder>',
|
||||
@@ -2115,9 +2120,9 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
expect(workspaceContextOf(restored)?.meta).toMatchObject({
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
|
||||
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
|
||||
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
@@ -2222,7 +2227,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
const update = resumed.session.events.findLast(event => event.type === 'context/message')
|
||||
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
|
||||
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
|
||||
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
})
|
||||
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
|
||||
} finally {
|
||||
@@ -2350,8 +2355,8 @@ describe('dynamic nested workspace context injection', () => {
|
||||
})
|
||||
|
||||
const firstText = blocksText(workspaceContextOf(first)?.content)
|
||||
expect(firstText).toContain('omitted pkg/AGENTS.md')
|
||||
expect(firstText).not.toContain('## pkg/AGENTS.md')
|
||||
expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`)
|
||||
expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`)
|
||||
expect(firstText).toContain('subtree rule')
|
||||
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
|
||||
} finally {
|
||||
@@ -2494,14 +2499,19 @@ describe('dynamic nested workspace context injection', () => {
|
||||
it('skips unreadable nested instruction files without attaching empty context', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
const ctx = new Context()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
const nested = join(root, 'pkg/AGENTS.md')
|
||||
await write(nested, 'nested package rule')
|
||||
await write(join(root, 'pkg/deep/file.txt'), 'hello')
|
||||
await chmod(nested, 0)
|
||||
const ctx = new Context()
|
||||
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RecordingFileSystem)
|
||||
const fs = ctx.fs as RecordingFileSystem
|
||||
fs.entries.set(join(root, '.git'), { type: 'directory' })
|
||||
fs.entries.set(nested, { type: 'file', content: 'nested package rule' })
|
||||
fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
|
||||
fs.throwOnRead.add(nested)
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
@@ -2513,8 +2523,9 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.additionalContexts).toBeUndefined()
|
||||
await chmod(nested, 0o600)
|
||||
expect(fs.readTargets).toContain(nested)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
@@ -2551,7 +2562,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
|
||||
expect(workspaceContextOf(result)?.meta).toMatchObject({
|
||||
kind: 'workspace-instructions',
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
|
||||
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
|
||||
})
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
|
||||
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
|
||||
|
||||
@@ -12,6 +12,10 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
- `ScopeLayer` Aggregate contract for one registry's complete global or exact-scope contribution; `isEmpty()` controls scoped-layer reclamation.
|
||||
- `ScopedLayers<L>` Own one eager global layer and lazy exact-scope layers. `peek()` never creates, `merge()` materializes insertion-ordered named shadows, and `effect()` derives visibility and ownership from the same context while returning the exact Cordis disposer.
|
||||
- `NamedEntries<V>` Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, and `insert()` returns an idempotent exact-entry undo.
|
||||
- `AnonymousEntries<V>` Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, and `append()` returns an idempotent exact-entry undo.
|
||||
|
||||
The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime assertion. It uses the generated `scoped-events.generated.ts` resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real `scopeTarget(base, key)` calls.
|
||||
|
||||
@@ -19,6 +23,8 @@ The optional `@deepseek-ai/dsh-scope/invariant` companion owns that runtime asse
|
||||
|
||||
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals.
|
||||
|
||||
Scope-aware services define a concrete `ScopeLayer` that aggregates their heterogeneous tables and domain helpers. `ScopedLayers.effect()` accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. `notify` defaults to `true`; the supplied callback owns whether observer failures throw or are contained. `EntryValues` remains internal, the storage classes are imported from the package root rather than a `/store` subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the [shared scoped-layer storage Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md).
|
||||
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
|
||||
export type { ScopeLayer } from './store.ts'
|
||||
|
||||
/** An opaque, identity-compared scope key. */
|
||||
export type ScopeKey = object
|
||||
|
||||
|
||||
247
packages/core/scope/src/store.ts
Normal file
247
packages/core/scope/src/store.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Shared insertion-ordered storage and effect ownership for scope-aware registries.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { scopeOf } from './index.ts'
|
||||
import type { ScopeKey } from './index.ts'
|
||||
|
||||
/** One scope's aggregate contribution to a registry. */
|
||||
export interface ScopeLayer {
|
||||
/** Whether every table in this layer is empty. */
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
/** Internal common read contract for the two entry-table implementations. */
|
||||
interface EntryValues<V> {
|
||||
values(): IterableIterator<V>
|
||||
isEmpty(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Insertion-ordered named entries with caller-owned duplicate diagnostics.
|
||||
*
|
||||
* Values are borrowed. Iterators are live within one nonempty table
|
||||
* generation; draining the table detaches them from later insertions. Each
|
||||
* successful insertion returns an idempotent undo for that exact entry.
|
||||
*/
|
||||
export class NamedEntries<V> implements EntryValues<V> {
|
||||
private data = new Map<string, V>()
|
||||
|
||||
constructor(
|
||||
private readonly duplicateError: (name: string) => Error,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Insert one unique name.
|
||||
* @param name - name unique within this table.
|
||||
* @param value - borrowed value to retain.
|
||||
* @returns an idempotent undo that removes only this insertion.
|
||||
*/
|
||||
insert(name: string, value: V): () => void {
|
||||
const data = this.data
|
||||
if (data.has(name)) throw this.duplicateError(name)
|
||||
data.set(name, value)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
data.delete(name)
|
||||
if (data.size === 0 && this.data === data) this.data = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one named value.
|
||||
* @param name - name to resolve.
|
||||
* @returns the retained value, or `undefined` when absent.
|
||||
*/
|
||||
get(name: string): V | undefined {
|
||||
return this.data.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one name for membership.
|
||||
* @param name - name to test.
|
||||
* @returns whether the table contains that name.
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.data.has(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live names in insertion order.
|
||||
* @returns the native live key iterator.
|
||||
*/
|
||||
keys(): IterableIterator<string> {
|
||||
return this.data.keys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live entries in insertion order.
|
||||
* @returns the native live entry iterator.
|
||||
*/
|
||||
entries(): IterableIterator<[string, V]> {
|
||||
return this.data.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live values in insertion order.
|
||||
* @returns the native live value iterator.
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.data.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this table has no entries.
|
||||
* @returns whether the table is empty.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insertion-ordered anonymous entries with independent registration identity.
|
||||
*
|
||||
* Equal values remain separate registrations. Values are borrowed, and
|
||||
* iterators are live within one nonempty table generation; draining the table
|
||||
* detaches them from later appends.
|
||||
*/
|
||||
export class AnonymousEntries<V> implements EntryValues<V> {
|
||||
private data = new Map<symbol, V>()
|
||||
|
||||
/**
|
||||
* Append one independently owned value.
|
||||
* @param value - borrowed value to retain.
|
||||
* @returns an idempotent undo for this exact append.
|
||||
*/
|
||||
append(value: V): () => void {
|
||||
const data = this.data
|
||||
const key = Symbol()
|
||||
data.set(key, value)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
data.delete(key)
|
||||
if (data.size === 0 && this.data === data) this.data = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate live values in insertion order.
|
||||
* @returns the native live value iterator.
|
||||
*/
|
||||
values(): IterableIterator<V> {
|
||||
return this.data.values()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether this table has no entries.
|
||||
* @returns whether the table is empty.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.size === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the global and exact-scope layers for one registry.
|
||||
*
|
||||
* Reads never create scoped layers. Registrations derive both visibility and
|
||||
* effect ownership from the supplied Cordis context, collect undo before
|
||||
* notification, and reclaim only a completely empty aggregate layer.
|
||||
*/
|
||||
export class ScopedLayers<L extends ScopeLayer> {
|
||||
/** The eagerly constructed context-global layer. */
|
||||
readonly global: L
|
||||
|
||||
private readonly scoped = new Map<ScopeKey, L>()
|
||||
|
||||
constructor(
|
||||
private readonly createLayer: (scope: ScopeKey | undefined) => L,
|
||||
private readonly onChange: () => void,
|
||||
) {
|
||||
this.global = createLayer(undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an existing exact-scope overlay.
|
||||
* @param scope - exact scope key; `undefined` denotes no overlay.
|
||||
* @returns the existing scoped layer, or `undefined` without creating one.
|
||||
*/
|
||||
peek(scope: ScopeKey | undefined): L | undefined {
|
||||
if (scope === undefined) return undefined
|
||||
return this.scoped.get(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize global named entries followed by exact-scope shadows.
|
||||
* @param scope - exact viewing scope, or `undefined` for the global view.
|
||||
* @param pick - select the named table from a layer.
|
||||
* @returns an insertion-ordered effective map.
|
||||
*/
|
||||
merge<V>(
|
||||
scope: ScopeKey | undefined,
|
||||
pick: (layer: L) => NamedEntries<V>,
|
||||
): Map<string, V> {
|
||||
const merged = new Map(pick(this.global).entries())
|
||||
const layer = this.peek(scope)
|
||||
if (layer === undefined) return merged
|
||||
for (const [name, value] of pick(layer).entries()) merged.set(name, value)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach one synchronous layer mutation to its registration context.
|
||||
* @param ctx - context that determines both scope visibility and effect ownership.
|
||||
* @param action - atomic mutation returning its synchronous undo.
|
||||
* @param options - Cordis effect label and optional change notification.
|
||||
* @returns the exact disposer returned by `ctx.effect()`.
|
||||
*/
|
||||
effect(
|
||||
ctx: Context,
|
||||
action: (layer: L) => () => void,
|
||||
options: { label: string; notify?: boolean },
|
||||
): () => void {
|
||||
const scope = scopeOf(ctx)
|
||||
const notify = options.notify ?? true
|
||||
const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
|
||||
let layer: L
|
||||
let created = false
|
||||
if (scope === undefined) {
|
||||
layer = this.global
|
||||
} else {
|
||||
const existing = this.scoped.get(scope)
|
||||
if (existing === undefined) {
|
||||
layer = this.createLayer(scope)
|
||||
this.scoped.set(scope, layer)
|
||||
created = true
|
||||
} else {
|
||||
layer = existing
|
||||
}
|
||||
}
|
||||
|
||||
let undo: () => void
|
||||
try {
|
||||
undo = action(layer)
|
||||
} catch (error) {
|
||||
if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
|
||||
throw error
|
||||
}
|
||||
|
||||
yield () => {
|
||||
undo()
|
||||
if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
|
||||
if (notify) this.onChange()
|
||||
}
|
||||
if (notify) this.onChange()
|
||||
}.bind(this), options.label)
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
|
||||
return dispose
|
||||
}
|
||||
}
|
||||
289
packages/core/scope/tests/store.spec.ts
Normal file
289
packages/core/scope/tests/store.spec.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
AnonymousEntries,
|
||||
createScope,
|
||||
NamedEntries,
|
||||
ScopedLayers,
|
||||
type Scope,
|
||||
type ScopeKey,
|
||||
type ScopeLayer,
|
||||
} from '@deepseek-ai/dsh-scope'
|
||||
|
||||
class TestLayer implements ScopeLayer {
|
||||
readonly named: NamedEntries<number>
|
||||
readonly anonymous = new AnonymousEntries<string>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.named = new NamedEntries(name =>
|
||||
new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.named.isEmpty() && this.anonymous.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint one active scope for lifecycle tests. */
|
||||
async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
|
||||
return scope
|
||||
}
|
||||
|
||||
describe('NamedEntries', () => {
|
||||
it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
|
||||
const duplicate = new Error('caller duplicate')
|
||||
const duplicateError = vi.fn(() => duplicate)
|
||||
const entries = new NamedEntries<number>(duplicateError)
|
||||
const undoA = entries.insert('a', 1)
|
||||
const values = entries.values()
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
const undoB = entries.insert('b', 2)
|
||||
|
||||
expect([...values]).toEqual([2])
|
||||
expect([...entries.keys()]).toEqual(['a', 'b'])
|
||||
expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
|
||||
expect(entries.get('a')).toBe(1)
|
||||
expect(entries.get('missing')).toBeUndefined()
|
||||
expect(entries.has('b')).toBe(true)
|
||||
expect(entries.has('missing')).toBe(false)
|
||||
expect(entries.isEmpty()).toBe(false)
|
||||
expect(() => entries.insert('a', 3)).toThrow(duplicate)
|
||||
expect(duplicateError).toHaveBeenCalledWith('a')
|
||||
|
||||
undoA()
|
||||
entries.insert('a', 3)
|
||||
undoA()
|
||||
expect(entries.get('a')).toBe(3)
|
||||
undoB()
|
||||
expect([...entries.entries()]).toEqual([['a', 3]])
|
||||
})
|
||||
|
||||
it('starts a fresh iterator generation after the table drains', () => {
|
||||
const entries = new NamedEntries<number>(name => new Error(`duplicate: ${name}`))
|
||||
const undo = entries.insert('first', 1)
|
||||
const values = entries.values()
|
||||
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
undo()
|
||||
entries.insert('replacement', 2)
|
||||
|
||||
expect(values.next().done).toBe(true)
|
||||
expect([...entries.values()]).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AnonymousEntries', () => {
|
||||
it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
|
||||
const entries = new AnonymousEntries<object>()
|
||||
const value = {}
|
||||
const undoFirst = entries.append(value)
|
||||
const values = entries.values()
|
||||
expect(values.next()).toEqual({ value, done: false })
|
||||
const undoSecond = entries.append(value)
|
||||
|
||||
expect([...values]).toEqual([value])
|
||||
expect([...entries.values()]).toEqual([value, value])
|
||||
undoFirst()
|
||||
undoFirst()
|
||||
expect([...entries.values()]).toEqual([value])
|
||||
undoSecond()
|
||||
expect(entries.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('starts a fresh iterator generation after the table drains', () => {
|
||||
const entries = new AnonymousEntries<number>()
|
||||
const undo = entries.append(1)
|
||||
const values = entries.values()
|
||||
|
||||
expect(values.next()).toEqual({ value: 1, done: false })
|
||||
undo()
|
||||
entries.append(2)
|
||||
|
||||
expect(values.next().done).toBe(true)
|
||||
expect([...entries.values()]).toEqual([2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScopedLayers', () => {
|
||||
it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
|
||||
const created: Array<ScopeKey | undefined> = []
|
||||
const layers = new ScopedLayers(
|
||||
(scope) => {
|
||||
created.push(scope)
|
||||
return new TestLayer(scope)
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
const key = {}
|
||||
layers.global.named.insert('a', 1)
|
||||
layers.global.named.insert('shared', 2)
|
||||
|
||||
expect(created).toEqual([undefined])
|
||||
expect(layers.peek(undefined)).toBeUndefined()
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
|
||||
expect(created).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
const changed = vi.fn()
|
||||
const created: Array<ScopeKey | undefined> = []
|
||||
const layers = new ScopedLayers(
|
||||
(selected) => {
|
||||
created.push(selected)
|
||||
return new TestLayer(selected)
|
||||
},
|
||||
changed,
|
||||
)
|
||||
layers.global.named.insert('a', 1)
|
||||
layers.global.named.insert('shared', 1)
|
||||
const removeNamed = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('shared', 2),
|
||||
{ label: 'test.named', notify: false },
|
||||
)
|
||||
const removeTail = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('c', 3),
|
||||
{ label: 'test.tail', notify: false },
|
||||
)
|
||||
const removeAnonymous = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.anonymous.append('kept'),
|
||||
{ label: 'test.anonymous', notify: false },
|
||||
)
|
||||
|
||||
expect(created).toEqual([undefined, key])
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
removeNamed()
|
||||
expect(layers.peek(key)).toBeDefined()
|
||||
expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
|
||||
removeTail()
|
||||
expect(layers.peek(key)).toBeDefined()
|
||||
removeAnonymous()
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
|
||||
const ctx = new Context()
|
||||
const events: string[] = []
|
||||
const layers = new ScopedLayers(
|
||||
scope => new TestLayer(scope),
|
||||
() => void events.push('notify'),
|
||||
)
|
||||
const dispose = layers.effect(
|
||||
ctx,
|
||||
(layer) => {
|
||||
events.push('action')
|
||||
const undo = layer.named.insert('x', 1)
|
||||
return () => {
|
||||
events.push('undo')
|
||||
undo()
|
||||
}
|
||||
},
|
||||
{ label: 'store.order' },
|
||||
)
|
||||
|
||||
expect(events).toEqual(['action', 'notify'])
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
|
||||
dispose()
|
||||
dispose()
|
||||
expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
|
||||
expect(layers.global.isEmpty()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the exact context effect disposer', () => {
|
||||
const rawDispose = vi.fn()
|
||||
const effect = vi.fn(() => rawDispose)
|
||||
const ctx = { effect } as unknown as Context
|
||||
const action = vi.fn(() => vi.fn())
|
||||
const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
|
||||
|
||||
const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
|
||||
|
||||
expect(returned).toBe(rawDispose)
|
||||
expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
let failFactory = true
|
||||
const layers = new ScopedLayers(
|
||||
(selected) => {
|
||||
if (selected !== undefined && failFactory) throw new Error('factory failed')
|
||||
return new TestLayer(selected)
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('never', 1),
|
||||
{ label: 'store.factory', notify: false },
|
||||
)).toThrow('factory failed')
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
|
||||
failFactory = false
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
() => { throw new Error('action failed') },
|
||||
{ label: 'store.action', notify: false },
|
||||
)).toThrow('action failed')
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
|
||||
const dispose = layers.effect(
|
||||
scope.ctx,
|
||||
layer => layer.named.insert('kept', 1),
|
||||
{ label: 'store.kept', notify: false },
|
||||
)
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
() => { throw new Error('second action failed') },
|
||||
{ label: 'store.existing-action', notify: false },
|
||||
)).toThrow('second action failed')
|
||||
expect(layers.peek(key)?.named.get('kept')).toBe(1)
|
||||
dispose()
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('rolls back a scoped insertion when notification throws', async () => {
|
||||
const ctx = new Context()
|
||||
const key = {}
|
||||
const scope = await mintScope(ctx, key)
|
||||
const events: string[] = []
|
||||
let notifications = 0
|
||||
const layers = new ScopedLayers(
|
||||
selected => new TestLayer(selected),
|
||||
() => {
|
||||
events.push('notify')
|
||||
if (++notifications === 1) throw new Error('change failed')
|
||||
},
|
||||
)
|
||||
|
||||
expect(() => layers.effect(
|
||||
scope.ctx,
|
||||
(layer) => {
|
||||
const undo = layer.named.insert('rollback', 1)
|
||||
return () => {
|
||||
events.push('undo')
|
||||
undo()
|
||||
}
|
||||
},
|
||||
{ label: 'store.rollback' },
|
||||
)).toThrow('change failed')
|
||||
|
||||
expect(events).toEqual(['notify', 'undo', 'notify'])
|
||||
expect(layers.peek(key)).toBeUndefined()
|
||||
await scope.dispose()
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -209,6 +209,39 @@ function interpolate(section: AssembledSection, variables: Record<string, string
|
||||
return result + text.slice(last)
|
||||
}
|
||||
|
||||
/** One tool-schema provider stored in a prompt layer. */
|
||||
type ToolProvider = (context: AssembleContext) => ToolProviderResult
|
||||
|
||||
/** One prompt-variable provider stored in a prompt layer. */
|
||||
type VariableProvider = (context: AssembleContext) => string | undefined
|
||||
|
||||
/** All prompt registrations owned by one global or scoped layer. */
|
||||
class PromptLayer implements ScopeLayer {
|
||||
readonly sections: NamedEntries<PromptSection>
|
||||
readonly toolProviders = new AnonymousEntries<ToolProvider>()
|
||||
readonly variables: NamedEntries<VariableProvider>
|
||||
|
||||
/**
|
||||
* Create one prompt layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.sections = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${name}" is already registered in this scope`))
|
||||
this.variables = new NamedEntries(name => new Error(scope === undefined
|
||||
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt variable "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no prompt registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.sections.isEmpty()
|
||||
&& this.toolProviders.isEmpty()
|
||||
&& this.variables.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry service for the prompt inputs assembled before each model step. */
|
||||
export class SystemPrompt extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -217,13 +250,10 @@ export class SystemPrompt extends Service {
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
|
||||
private scopedSections = new Map<ScopeKey, PromptSection[]>()
|
||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new PromptLayer(scope),
|
||||
() => { this.ctx.emit('system-prompt/change') },
|
||||
)
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -255,34 +285,11 @@ export class SystemPrompt extends Service {
|
||||
if (!Number.isFinite(section.order)) {
|
||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.sections
|
||||
: this.scopedSections.get(scope) ?? (() => {
|
||||
const created: PromptSection[] = []
|
||||
this.scopedSections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.some(existing => existing.name === section.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${section.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.push(section)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
const index = layer.indexOf(section)
|
||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.section()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.sections.insert(section.name, section),
|
||||
{ label: 'systemPrompt.section()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,29 +300,11 @@ export class SystemPrompt extends Service {
|
||||
* @returns the exact Cordis effect disposer.
|
||||
*/
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.toolProviders
|
||||
: this.scopedToolProviders.get(scope) ?? (() => {
|
||||
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
this.scopedToolProviders.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
layer.push(provider)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
const index = layer.indexOf(provider)
|
||||
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.tools()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.toolProviders.append(provider),
|
||||
{ label: 'systemPrompt.tools()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,32 +319,11 @@ export class SystemPrompt extends Service {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
const layer = scope === undefined
|
||||
? this.variableProviders
|
||||
: this.scopedVariableProviders.get(scope) ?? (() => {
|
||||
const created = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
this.scopedVariableProviders.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt variable "${name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(name, provider)
|
||||
// Install rollback before notifying listeners that may throw.
|
||||
yield () => {
|
||||
layer.delete(name)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.variable()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.variables.insert(name, provider),
|
||||
{ label: 'systemPrompt.variable()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -370,23 +338,19 @@ export class SystemPrompt extends Service {
|
||||
const scope = context.scope
|
||||
// Scoped variables shadow globals.
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
for (const [name, provider] of this.layers.global.variables.entries()) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
|
||||
for (const [name, provider] of scopedVariables ?? []) {
|
||||
const scopedVariables = this.layers.peek(scope)?.variables
|
||||
for (const [name, provider] of scopedVariables?.entries() ?? []) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
// Scoped sections shadow globals before the stable order sort.
|
||||
const sectionByName = new Map<string, PromptSection>()
|
||||
for (const section of this.sections) sectionByName.set(section.name, section)
|
||||
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
|
||||
sectionByName.set(section.name, section)
|
||||
}
|
||||
const sectionByName = this.layers.merge(scope, layer => layer.sections)
|
||||
// Validate order against pre-restriction names while collecting visible schemas.
|
||||
const providers = [
|
||||
...this.toolProviders,
|
||||
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
|
||||
...this.layers.global.toolProviders.values(),
|
||||
...(this.layers.peek(scope)?.toolProviders.values() ?? []),
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
@@ -63,6 +63,21 @@ describe('scoped sections', () => {
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('shadows a global section before evaluating either text provider', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const globalText = vi.fn(() => 'global text')
|
||||
const scopedText = vi.fn(() => 'scoped text')
|
||||
ctx.systemPrompt.section({ name: 'shared', order: 1, text: globalText })
|
||||
scope.ctx.systemPrompt.section({ name: 'shared', order: 1, text: scopedText })
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
|
||||
expect(assembly.sections.find(section => section.name === 'shared')?.text).toBe('scoped text')
|
||||
expect(globalText).not.toHaveBeenCalled()
|
||||
expect(scopedText).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
@@ -86,6 +101,28 @@ describe('scoped variables', () => {
|
||||
const again = await mintScope(ctx, 'child2')
|
||||
again.ctx.systemPrompt.variable('v', () => '3')
|
||||
})
|
||||
|
||||
it('defers a scoped variable that replaces the last provider in its generation', async () => {
|
||||
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const key = scopeKeyOf(scope)
|
||||
const calls: string[] = []
|
||||
scope.ctx.systemPrompt.section({ name: 'scope:sibling', order: 1, text: 'Scoped.' })
|
||||
const dispose = scope.ctx.systemPrompt.variable('mode', () => {
|
||||
calls.push('first')
|
||||
dispose()
|
||||
scope.ctx.systemPrompt.variable('mode', () => {
|
||||
calls.push('replacement')
|
||||
return 'replacement'
|
||||
})
|
||||
return 'first'
|
||||
})
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: first.')
|
||||
expect(calls).toEqual(['first'])
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))).toContain('Mode: replacement.')
|
||||
expect(calls).toEqual(['first', 'replacement'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped tool providers and toolOrder × restriction', () => {
|
||||
|
||||
@@ -157,6 +157,24 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
it('snapshots tool-provider membership before evaluating an assembly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let added = false
|
||||
ctx.systemPrompt.tools(() => {
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
schemas: [{ name: 'late', description: '', parameters: {} }],
|
||||
}))
|
||||
}
|
||||
return { schemas: [{ name: 'first', description: '', parameters: {} }] }
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first'])
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['first', 'late'])
|
||||
})
|
||||
|
||||
it('rolls back a variable when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -314,6 +332,24 @@ describe('SystemPrompt', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
|
||||
})
|
||||
|
||||
it('live-iterates variables registered by an earlier provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
let added = false
|
||||
ctx.systemPrompt.variable('first', () => {
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.systemPrompt.variable('late', () => 'second value')
|
||||
}
|
||||
return 'first value'
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toEqual({
|
||||
first: 'first value',
|
||||
late: 'second value',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a duplicate variable name and an unreferenceable name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
@@ -463,9 +463,40 @@ interface ToolView {
|
||||
*/
|
||||
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
/** One guard registration; the wrapper preserves independent duplicate registrations. */
|
||||
interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
/** One scope's complete tool-registry contribution. */
|
||||
class ToolLayer implements ScopeLayer {
|
||||
readonly tools: NamedEntries<ToolDefinition>
|
||||
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
|
||||
readonly guards = new AnonymousEntries<ToolGuard>()
|
||||
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.tools = new NamedEntries(name => new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** Whether every contribution table in this aggregate layer is empty. */
|
||||
isEmpty(): boolean {
|
||||
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
|
||||
}
|
||||
|
||||
/** Whether every compiled restriction in this layer admits a global tool name. */
|
||||
admits(name: string): boolean {
|
||||
for (const filter of this.restrictions.values()) {
|
||||
if ((filter.allow !== undefined && !filter.allow.has(name))
|
||||
|| (filter.deny !== undefined && filter.deny.has(name))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** First monotonic denial from this layer's live guard registrations. */
|
||||
guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const guard of this.guards.values()) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Approval decision plus whether the approval channel reported cancellation. */
|
||||
@@ -509,13 +540,10 @@ export class ToolRegistry extends Service {
|
||||
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
private global = new Map<string, ToolDefinition>()
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Compiled restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
|
||||
/** Monotonic post-policy guards, split into global and per-agent layers. */
|
||||
private globalGuards = new Set<ToolGuardRegistration>()
|
||||
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new ToolLayer(scope),
|
||||
() => { this.ctx.emit('tools/change') },
|
||||
)
|
||||
private readonly mode: ToolPresentationMode
|
||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||
private readonly codeTransport: ToolDefinition | undefined
|
||||
@@ -593,7 +621,6 @@ export class ToolRegistry extends Service {
|
||||
* @returns the exact disposer that unregisters the tool.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const name = definition.name
|
||||
const timeoutMs = definition.timeoutMs
|
||||
if (timeoutMs !== undefined
|
||||
@@ -603,26 +630,11 @@ export class ToolRegistry extends Service {
|
||||
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
|
||||
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(name, definition)
|
||||
// Install rollback before notifying listeners.
|
||||
yield () => {
|
||||
layer.delete(name)
|
||||
// Drop empty scope layers.
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.tools.insert(name, definition),
|
||||
{ label: 'tools.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -655,22 +667,11 @@ export class ToolRegistry extends Service {
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const list = this.restrictions.get(scope) ?? []
|
||||
this.restrictions.set(scope, list)
|
||||
list.push(compiled)
|
||||
yield () => {
|
||||
const index = list.indexOf(compiled)
|
||||
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) list.splice(index, 1)
|
||||
if (list.length === 0) this.restrictions.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.restrict()')
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.restrictions.append(compiled),
|
||||
{ label: 'tools.restrict()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -684,63 +685,18 @@ export class ToolRegistry extends Service {
|
||||
* @returns the exact disposer that unregisters the guard.
|
||||
*/
|
||||
guard(guard: ToolGuard): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registration = { guard }
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
|
||||
layer.add(registration)
|
||||
yield () => {
|
||||
layer.delete(registration)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
||||
}
|
||||
}.bind(this), 'tools.guard()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** The (created-on-demand) scoped layer for `scope`. */
|
||||
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (!layer) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** Get or create the guard layer for one agent scope. */
|
||||
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
|
||||
let layer = this.scopedGuards.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Set()
|
||||
this.scopedGuards.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.guards.append(guard),
|
||||
{ label: 'tools.guard()', notify: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** First monotonic denial from the global then matching scoped guard layers. */
|
||||
private guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const { guard } of this.globalGuards) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
|
||||
private admits(scope: ScopeKey | undefined, name: string): boolean {
|
||||
if (scope === undefined) return true
|
||||
const filters = this.restrictions.get(scope)
|
||||
if (!filters) return true
|
||||
return filters.every(filter =>
|
||||
(filter.allow === undefined || filter.allow.has(name))
|
||||
&& (filter.deny === undefined || !filter.deny.has(name)))
|
||||
const globalReason = this.layers.global.guardReason(exec)
|
||||
if (globalReason !== undefined) return globalReason
|
||||
return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,18 +708,18 @@ export class ToolRegistry extends Service {
|
||||
* @returns the complete derived view for that scope.
|
||||
*/
|
||||
private view(scope?: ScopeKey): ToolView {
|
||||
const layer = scope === undefined ? undefined : this.scoped.get(scope)
|
||||
const layer = this.layers.peek(scope)
|
||||
const visible = new Map<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
for (const [name, definition] of this.global) {
|
||||
for (const [name, definition] of this.layers.global.tools.entries()) {
|
||||
knownNames.add(name)
|
||||
restrictableNames.add(name)
|
||||
if (this.admits(scope, name)) visible.set(name, definition)
|
||||
if (layer?.admits(name) ?? true) visible.set(name, definition)
|
||||
}
|
||||
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
|
||||
// and scope-local registrations are never part of the global filter above.
|
||||
for (const [name, definition] of layer ?? []) {
|
||||
for (const [name, definition] of layer?.tools.entries() ?? []) {
|
||||
knownNames.add(name)
|
||||
visible.set(name, definition)
|
||||
}
|
||||
|
||||
@@ -266,6 +266,49 @@ describe('scoped execution dispatch', () => {
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('live-iterates a guard registered by an earlier guard', async () => {
|
||||
const ctx = await mount()
|
||||
const calls: string[] = []
|
||||
let added = false
|
||||
ctx.tools.register(tool('t'))
|
||||
ctx.tools.guard(() => {
|
||||
calls.push('first')
|
||||
if (!added) {
|
||||
added = true
|
||||
ctx.tools.guard(() => {
|
||||
calls.push('late')
|
||||
return 'late denial'
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't')).toBe('Error: late denial')
|
||||
expect(calls).toEqual(['first', 'late'])
|
||||
})
|
||||
|
||||
it('defers a scoped guard that replaces the last guard in its generation', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const calls: string[] = []
|
||||
ctx.tools.register(tool('t'))
|
||||
scope.ctx.tools.register(tool('scope_sibling'))
|
||||
const lift = scope.ctx.tools.guard(() => {
|
||||
calls.push('first')
|
||||
lift()
|
||||
scope.ctx.tools.guard(() => {
|
||||
calls.push('replacement')
|
||||
return 'replacement denial'
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||
expect(calls).toEqual(['first'])
|
||||
expect(await run(ctx, 't', key)).toBe('Error: replacement denial')
|
||||
expect(calls).toEqual(['first', 'replacement'])
|
||||
})
|
||||
|
||||
it('shares one token and materialized argument value across the pipeline', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
|
||||
@@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
@@ -74,10 +75,16 @@ function versionOf(info: BigIntStats): FsVersion {
|
||||
* file before it is renamed over the target.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override the host platform for native-publication unit coverage. */
|
||||
platform?: NodeJS.Platform
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
tempDirName?: (writePath: string) => string
|
||||
/** Override the generated temp-file name (relative to the private staging dir). */
|
||||
tempName?: (writePath: string) => string
|
||||
/** Override the Win32 DACL copy boundary. */
|
||||
copyFileDacl?: (source: string, destination: string) => Promise<void>
|
||||
/** Override the Win32 security-preserving replacement boundary. */
|
||||
replaceFile?: (replaced: string, replacement: string) => Promise<void>
|
||||
/** Test hook after the temp file is written/synced but before final chmod+rename. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
}
|
||||
@@ -133,6 +140,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
// A path component is a file, not a directory (e.g. "afile/child.txt" where
|
||||
// "afile" is a regular file): the target can neither exist nor be created,
|
||||
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
|
||||
/* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
|
||||
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
|
||||
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
|
||||
if (!isENOENT(error)) throw error
|
||||
@@ -145,8 +153,22 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
while (true) {
|
||||
try {
|
||||
const realAncestor = await realpath(ancestor)
|
||||
// On Windows, realpath of a regular file succeeds where POSIX returns
|
||||
// ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).
|
||||
// Stat the ancestor to restore the semantic distinction: a non-directory
|
||||
// ancestor means the target passes through a file and can never be created.
|
||||
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
|
||||
if (process.platform === 'win32') {
|
||||
const parentInfo = await stat(realAncestor)
|
||||
if (!parentInfo.isDirectory()) {
|
||||
throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
|
||||
if (error instanceof FsError) throw error
|
||||
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
|
||||
if (!isENOENT(error)) throw error
|
||||
const parent = dirname(ancestor)
|
||||
@@ -160,7 +182,9 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
|
||||
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
|
||||
if (info.isFile()) return 'file'
|
||||
/* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
|
||||
if (info.isDirectory()) return 'directory'
|
||||
/* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
|
||||
return 'other'
|
||||
}
|
||||
|
||||
@@ -224,6 +248,7 @@ function listingIoError(displayPath: string, error: unknown): FsError {
|
||||
if (error instanceof FsError) return error
|
||||
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
|
||||
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
|
||||
/* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
|
||||
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
|
||||
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
|
||||
}
|
||||
@@ -394,9 +419,13 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
|
||||
|
||||
/**
|
||||
* Atomically replace a file through a private, synced staging file in the same directory.
|
||||
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
|
||||
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
|
||||
* onto the empty temp before writing and preserves the target descriptor at publication.
|
||||
* @param absolutePath - destination; missing parent directories are created.
|
||||
* @param content - the full UTF-8 text to write.
|
||||
* @param mode - final mode, or `0o600` when omitted.
|
||||
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
|
||||
* inert as a mode on Windows but identifies replacement security semantics.
|
||||
* @param signal - cancellation checked before the final rename.
|
||||
* @param internals - test seam for pinning temp names and observing the staged file.
|
||||
*/
|
||||
@@ -416,6 +445,9 @@ export async function writeFileAtomic(
|
||||
const stagingDir = join(directory, stagingDirName)
|
||||
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
|
||||
const tempPath = join(stagingDir, tempName)
|
||||
const platform = internals.platform ?? process.platform
|
||||
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
|
||||
const replaceFile = internals.replaceFile ?? replaceFileWin32
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
let stagingCreated = false
|
||||
try {
|
||||
@@ -425,6 +457,9 @@ export async function writeFileAtomic(
|
||||
|
||||
handle = await open(tempPath, 'wx', 0o600)
|
||||
await handle.chmod(0o600)
|
||||
if (platform === 'win32' && mode !== undefined) {
|
||||
await copyFileDacl(absolutePath, tempPath)
|
||||
}
|
||||
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
|
||||
await handle.sync()
|
||||
await internals.inspectTemp?.({ stagingDir, tempPath })
|
||||
@@ -433,7 +468,18 @@ export async function writeFileAtomic(
|
||||
handle = undefined
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
await rename(tempPath, absolutePath)
|
||||
if (platform === 'win32' && mode !== undefined) {
|
||||
try {
|
||||
await replaceFile(absolutePath, tempPath)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the old behavior when an external actor removes the observed target during
|
||||
// staging: the temp already carries that target's protected DACL, so rename recreates it.
|
||||
if (!isENOENT(error)) throw error
|
||||
await rename(tempPath, absolutePath)
|
||||
}
|
||||
} else {
|
||||
await rename(tempPath, absolutePath)
|
||||
}
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
|
||||
|
||||
134
packages/fs/fs-local/src/win32.ts
Normal file
134
packages/fs/fs-local/src/win32.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
|
||||
* non-Windows processes never open Win32 libraries.
|
||||
* @module @deepseek-ai/dsh-fs-local/win32
|
||||
*/
|
||||
|
||||
import { toNamespacedPath } from 'node:path'
|
||||
|
||||
type GetFileSecurityW = (
|
||||
path: string,
|
||||
requestedInformation: number,
|
||||
descriptor: Buffer | null,
|
||||
length: number,
|
||||
needed: [number],
|
||||
) => number
|
||||
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
|
||||
type ReplaceFileW = (
|
||||
replaced: string,
|
||||
replacement: string,
|
||||
backup: null,
|
||||
flags: number,
|
||||
exclude: null,
|
||||
reserved: null,
|
||||
) => number
|
||||
type GetLastError = () => number
|
||||
|
||||
interface Win32Bindings {
|
||||
getFileSecurityW: GetFileSecurityW
|
||||
setFileSecurityW: SetFileSecurityW
|
||||
replaceFileW: ReplaceFileW
|
||||
getLastError: GetLastError
|
||||
}
|
||||
|
||||
interface Win32ErrnoException extends NodeJS.ErrnoException {
|
||||
win32Code: number
|
||||
}
|
||||
|
||||
const DACL_SECURITY_INFORMATION = 0x00000004
|
||||
const PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
|
||||
const ERROR_FILE_NOT_FOUND = 2
|
||||
const ERROR_PATH_NOT_FOUND = 3
|
||||
const ERROR_ACCESS_DENIED = 5
|
||||
|
||||
let bindings: Win32Bindings | undefined
|
||||
|
||||
async function win32(): Promise<Win32Bindings> {
|
||||
if (bindings !== undefined) return bindings
|
||||
const koffi = (await import('koffi')).default
|
||||
const advapi32 = koffi.load('advapi32.dll')
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
bindings = {
|
||||
getFileSecurityW: advapi32.func('int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)') as GetFileSecurityW,
|
||||
setFileSecurityW: advapi32.func('int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)') as SetFileSecurityW,
|
||||
replaceFileW: kernel32.func('int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)') as ReplaceFileW,
|
||||
getLastError: kernel32.func('uint32_t __stdcall GetLastError()') as GetLastError,
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
function errnoCode(win32Code: number): string {
|
||||
switch (win32Code) {
|
||||
case ERROR_FILE_NOT_FOUND:
|
||||
case ERROR_PATH_NOT_FOUND:
|
||||
return 'ENOENT'
|
||||
case ERROR_ACCESS_DENIED:
|
||||
return 'EACCES'
|
||||
default:
|
||||
return 'EIO'
|
||||
}
|
||||
}
|
||||
|
||||
function win32Error(syscall: string, win32Code: number, path: string): Win32ErrnoException {
|
||||
const code = errnoCode(win32Code)
|
||||
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`) as Win32ErrnoException
|
||||
error.code = code
|
||||
error.errno = win32Code
|
||||
error.syscall = syscall
|
||||
error.path = path
|
||||
error.win32Code = win32Code
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's self-relative DACL security descriptor.
|
||||
* @param path - existing file whose DACL is read.
|
||||
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
|
||||
*/
|
||||
export async function readFileDaclWin32(path: string): Promise<Buffer> {
|
||||
const api = await win32()
|
||||
const nativePath = toNamespacedPath(path)
|
||||
const needed: [number] = [0]
|
||||
api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed)
|
||||
if (needed[0] === 0) throw win32Error('GetFileSecurityW', api.getLastError(), path)
|
||||
|
||||
const descriptor = Buffer.alloc(needed[0])
|
||||
if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) {
|
||||
throw win32Error('GetFileSecurityW', api.getLastError(), path)
|
||||
}
|
||||
return descriptor.subarray(0, needed[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
|
||||
* The destination must still be empty when confidentiality depends on this call.
|
||||
* @param source - existing file whose DACL is copied.
|
||||
* @param destination - existing file that receives the protected DACL.
|
||||
*/
|
||||
export async function copyFileDaclWin32(source: string, destination: string): Promise<void> {
|
||||
const descriptor = await readFileDaclWin32(source)
|
||||
const api = await win32()
|
||||
const information = (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) >>> 0
|
||||
if (api.setFileSecurityW(toNamespacedPath(destination), information, descriptor) === 0) {
|
||||
throw win32Error('SetFileSecurityW', api.getLastError(), destination)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
|
||||
* @param replaced - existing destination file.
|
||||
* @param replacement - closed staging file on the same volume.
|
||||
*/
|
||||
export async function replaceFileWin32(replaced: string, replacement: string): Promise<void> {
|
||||
const api = await win32()
|
||||
if (api.replaceFileW(
|
||||
toNamespacedPath(replaced),
|
||||
toNamespacedPath(replacement),
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
) === 0) {
|
||||
throw win32Error('ReplaceFileW', api.getLastError(), replaced)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
writeFileAtomic,
|
||||
} from '../src/fsio.ts'
|
||||
import type { LocalTarget } from '../src/fsio.ts'
|
||||
import { copyFileDaclWin32, readFileDaclWin32 } from '../src/win32.ts'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
@@ -367,24 +368,135 @@ describe('streamWholeText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Windows drives only the read-only attribute through `chmod` and reports synthetic `stat` mode
|
||||
// bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately.
|
||||
const posixModes = process.platform !== 'win32'
|
||||
|
||||
function daclAcePolicy(descriptor: Buffer): string[] {
|
||||
const daclOffset = descriptor.readUInt32LE(16)
|
||||
if (daclOffset === 0) return []
|
||||
const aceCount = descriptor.readUInt16LE(daclOffset + 4)
|
||||
const policy: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let offset = daclOffset + 8
|
||||
for (let index = 0; index < aceCount; index++) {
|
||||
const size = descriptor.readUInt16LE(offset + 2)
|
||||
const ace = Buffer.from(descriptor.subarray(offset, offset + size))
|
||||
// INHERITED_ACE records provenance, not the entry's access policy.
|
||||
ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
|
||||
const key = ace.toString('hex')
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
policy.push(key)
|
||||
}
|
||||
offset += size
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
describe('writeFileAtomic — temp-file safety', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'old')
|
||||
if (posixModes) await chmod(file, 0o640)
|
||||
let inspected = false
|
||||
await writeFileAtomic(file, 'hello', 0o640, undefined, {
|
||||
inspectTemp: async ({ stagingDir, tempPath }) => {
|
||||
inspected = true
|
||||
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
|
||||
const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)])
|
||||
expect(staging.isDirectory()).toBe(true)
|
||||
expect(temp.isFile()).toBe(true)
|
||||
if (posixModes) {
|
||||
expect(staging.mode & 0o777).toBe(0o700)
|
||||
expect(temp.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o640)
|
||||
if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('creates new files owner-only by default', async () => {
|
||||
it.skipIf(process.platform !== 'win32')('protects staged content with the existing target DACL and preserves it after replacement', async () => {
|
||||
const file = join(dir, 'protected.txt')
|
||||
await writeFile(file, 'old')
|
||||
await copyFileDaclWin32(file, file)
|
||||
const expectedDacl = await readFileDaclWin32(file)
|
||||
|
||||
await writeFileAtomic(file, 'new', (await stat(file)).mode, undefined, {
|
||||
inspectTemp: async ({ tempPath }) => {
|
||||
expect(await readFileDaclWin32(tempPath)).toEqual(expectedDacl)
|
||||
},
|
||||
})
|
||||
|
||||
expect(await readFile(file, 'utf8')).toBe('new')
|
||||
expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl))
|
||||
})
|
||||
|
||||
it('copies a Windows target DACL before content and publishes through secure replacement', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'old')
|
||||
const calls: string[] = []
|
||||
|
||||
await writeFileAtomic(file, 'new', 0o666, undefined, {
|
||||
platform: 'win32',
|
||||
copyFileDacl: async (source, temp) => {
|
||||
calls.push(`copy:${source}`)
|
||||
expect(await readFile(temp, 'utf8')).toBe('')
|
||||
},
|
||||
replaceFile: async (target, temp) => {
|
||||
calls.push(`replace:${target}`)
|
||||
await rename(temp, target)
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toEqual([`copy:${file}`, `replace:${file}`])
|
||||
expect(await readFile(file, 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('creates a new Windows file through directory inheritance without replacement calls', async () => {
|
||||
const file = join(dir, 'new.txt')
|
||||
const unexpected = async (): Promise<void> => { throw new Error('unexpected native replacement call') }
|
||||
|
||||
await writeFileAtomic(file, 'new', undefined, undefined, {
|
||||
platform: 'win32',
|
||||
copyFileDacl: unexpected,
|
||||
replaceFile: unexpected,
|
||||
})
|
||||
|
||||
expect(await readFile(file, 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('recreates a vanished Windows target with the already-protected temp', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'old')
|
||||
const missing = Object.assign(new Error('target vanished'), { code: 'ENOENT' })
|
||||
|
||||
await writeFileAtomic(file, 'new', 0o666, undefined, {
|
||||
platform: 'win32',
|
||||
copyFileDacl: () => Promise.resolve(),
|
||||
replaceFile: async () => { throw missing },
|
||||
})
|
||||
|
||||
expect(await readFile(file, 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('surfaces a Windows secure-replacement failure and cleans the staging directory', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'old')
|
||||
const denied = Object.assign(new Error('replace denied'), { code: 'EACCES' })
|
||||
|
||||
await expect(writeFileAtomic(file, 'new', 0o666, undefined, {
|
||||
platform: 'win32',
|
||||
copyFileDacl: () => Promise.resolve(),
|
||||
replaceFile: async () => { throw denied },
|
||||
})).rejects.toBe(denied)
|
||||
expect(await readFile(file, 'utf8')).toBe('old')
|
||||
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
|
||||
146
packages/fs/fs-local/tests/win32.spec.ts
Normal file
146
packages/fs/fs-local/tests/win32.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/** Host-independent binding tests for the Win32 DACL and replacement helpers. */
|
||||
|
||||
import { toNamespacedPath } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type GetFileSecurityW = (
|
||||
path: string,
|
||||
requestedInformation: number,
|
||||
descriptor: Buffer | null,
|
||||
length: number,
|
||||
needed: [number],
|
||||
) => number
|
||||
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
|
||||
type ReplaceFileW = (
|
||||
replaced: string,
|
||||
replacement: string,
|
||||
backup: null,
|
||||
flags: number,
|
||||
exclude: null,
|
||||
reserved: null,
|
||||
) => number
|
||||
|
||||
interface NativeMock {
|
||||
getFileSecurityW: GetFileSecurityW
|
||||
setFileSecurityW: SetFileSecurityW
|
||||
replaceFileW: ReplaceFileW
|
||||
getLastError: () => number
|
||||
}
|
||||
|
||||
async function importWithNative(native: NativeMock): Promise<typeof import('../src/win32.ts')> {
|
||||
vi.resetModules()
|
||||
vi.doMock('koffi', () => ({
|
||||
default: {
|
||||
load: () => ({
|
||||
func: (definition: string) => {
|
||||
if (definition.includes('GetFileSecurityW')) return native.getFileSecurityW
|
||||
if (definition.includes('SetFileSecurityW')) return native.setFileSecurityW
|
||||
if (definition.includes('ReplaceFileW')) return native.replaceFileW
|
||||
if (definition.includes('GetLastError')) return native.getLastError
|
||||
throw new Error(`unexpected native function: ${definition}`)
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
return import('../src/win32.ts')
|
||||
}
|
||||
|
||||
function successfulNative(descriptor: Buffer): NativeMock & { installed: Buffer[]; replacements: string[][] } {
|
||||
let lastError = 0
|
||||
const installed: Buffer[] = []
|
||||
const replacements: string[][] = []
|
||||
return {
|
||||
installed,
|
||||
replacements,
|
||||
getLastError: () => lastError,
|
||||
getFileSecurityW: (_path, _requested, output, _length, needed) => {
|
||||
needed[0] = descriptor.length
|
||||
if (output === null) {
|
||||
lastError = 122
|
||||
return 0
|
||||
}
|
||||
descriptor.copy(output)
|
||||
lastError = 0
|
||||
return 1
|
||||
},
|
||||
setFileSecurityW: (_path, information, value) => {
|
||||
expect(information).toBe(0x80000004)
|
||||
installed.push(Buffer.from(value))
|
||||
lastError = 0
|
||||
return 1
|
||||
},
|
||||
replaceFileW: (replaced, replacement, backup, flags, exclude, reserved) => {
|
||||
expect([backup, flags, exclude, reserved]).toEqual([null, 0, null, null])
|
||||
replacements.push([replaced, replacement])
|
||||
lastError = 0
|
||||
return 1
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock('koffi')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('Windows file-security helpers', () => {
|
||||
it('reads and installs a protected DACL before replacing the destination', async () => {
|
||||
const descriptor = Buffer.from([1, 2, 3, 4])
|
||||
const native = successfulNative(descriptor)
|
||||
const { copyFileDaclWin32, readFileDaclWin32, replaceFileWin32 } = await importWithNative(native)
|
||||
|
||||
expect(await readFileDaclWin32('source')).toEqual(descriptor)
|
||||
await copyFileDaclWin32('source', 'temp')
|
||||
expect(native.installed).toEqual([descriptor])
|
||||
await replaceFileWin32('target', 'temp')
|
||||
expect(native.replacements).toEqual([[toNamespacedPath('target'), toNamespacedPath('temp')]])
|
||||
})
|
||||
|
||||
it('maps descriptor-size probe failures to Node-style codes', async () => {
|
||||
const cases = [[2, 'ENOENT'], [3, 'ENOENT'], [5, 'EACCES'], [9999, 'EIO']] as const
|
||||
for (const [win32Code, code] of cases) {
|
||||
const native = successfulNative(Buffer.from([1]))
|
||||
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
|
||||
needed[0] = 0
|
||||
return 0
|
||||
}
|
||||
native.getLastError = () => win32Code
|
||||
const { readFileDaclWin32 } = await importWithNative(native)
|
||||
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code, win32Code, path: 'source' })
|
||||
}
|
||||
})
|
||||
|
||||
it('surfaces a descriptor read failure after the size probe', async () => {
|
||||
const native = successfulNative(Buffer.from([1, 2]))
|
||||
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
|
||||
needed[0] = 2
|
||||
return 0
|
||||
}
|
||||
native.getLastError = () => 5
|
||||
const { readFileDaclWin32 } = await importWithNative(native)
|
||||
|
||||
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code: 'EACCES', syscall: 'GetFileSecurityW' })
|
||||
})
|
||||
|
||||
it('surfaces DACL installation and replacement failures', async () => {
|
||||
const setFailure = successfulNative(Buffer.from([1]))
|
||||
setFailure.setFileSecurityW = () => 0
|
||||
setFailure.getLastError = () => 5
|
||||
const setModule = await importWithNative(setFailure)
|
||||
await expect(setModule.copyFileDaclWin32('source', 'temp')).rejects.toMatchObject({
|
||||
code: 'EACCES',
|
||||
syscall: 'SetFileSecurityW',
|
||||
path: 'temp',
|
||||
})
|
||||
|
||||
const replaceFailure = successfulNative(Buffer.from([1]))
|
||||
replaceFailure.replaceFileW = () => 0
|
||||
replaceFailure.getLastError = () => 2
|
||||
const replaceModule = await importWithNative(replaceFailure)
|
||||
await expect(replaceModule.replaceFileWin32('target', 'temp')).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
syscall: 'ReplaceFileW',
|
||||
path: 'target',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,14 +9,14 @@ Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../.
|
||||
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
|
||||
|
||||
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
|
||||
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `danger-full-access` — delegates unfenced.
|
||||
|
||||
## Threat model: a policy fence, not a kernel boundary
|
||||
|
||||
The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here.
|
||||
|
||||
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
|
||||
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
76
packages/fs/fs-sandbox/src/containment.ts
Normal file
76
packages/fs/fs-sandbox/src/containment.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Path-containment mechanics for the filesystem sandbox. Canonical spellings
|
||||
* take the fast lexical path; filesystem identity supplies the conservative
|
||||
* fallback for alias-equivalent roots such as Windows 8.3 names and casing.
|
||||
* @module @deepseek-ai/dsh-fs-sandbox/containment
|
||||
*/
|
||||
|
||||
import type { BigIntStats } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { dirname, sep } from 'node:path'
|
||||
|
||||
const MISSING_CODES: ReadonlySet<NodeJS.ErrnoException['code']> = new Set(['ENOENT', 'ENOTDIR'])
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
return MISSING_CODES.has(code)
|
||||
}
|
||||
|
||||
function comparablePath(path: string, caseSensitive: boolean): string {
|
||||
return caseSensitive ? path : path.toLowerCase()
|
||||
}
|
||||
|
||||
function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): boolean {
|
||||
const comparableTarget = comparablePath(path, caseSensitive)
|
||||
const comparableRoot = comparablePath(root, caseSensitive)
|
||||
if (comparableTarget === comparableRoot) return true
|
||||
const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep
|
||||
return comparableTarget.startsWith(prefix)
|
||||
}
|
||||
|
||||
async function statIfPresent(path: string): Promise<BigIntStats | undefined> {
|
||||
try {
|
||||
return await stat(path, { bigint: true })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */
|
||||
if (isMissing(error)) return undefined
|
||||
/* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdentity(left: BigIntStats, right: BigIntStats): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a canonical target is a writable root or lies beneath it.
|
||||
* The lexical fast path handles normal canonical spellings. When spellings
|
||||
* differ, walk the target's existing ancestors and compare filesystem identity
|
||||
* with the root; this recognizes Windows long-name/8.3 aliases and casing
|
||||
* without weakening containment to a textual approximation.
|
||||
* @param path - canonical target key, which may end in a missing suffix.
|
||||
* @param root - canonical writable root.
|
||||
* @param caseSensitive - whether lexical comparison preserves case; defaults
|
||||
* to the host filesystem convention used by supported platforms.
|
||||
* @returns whether the target is the root or a descendant of it.
|
||||
*/
|
||||
export async function isPathUnder(
|
||||
path: string,
|
||||
root: string,
|
||||
caseSensitive = process.platform !== 'win32',
|
||||
): Promise<boolean> {
|
||||
if (isLexicallyUnder(path, root, caseSensitive)) return true
|
||||
|
||||
const rootInfo = await statIfPresent(root)
|
||||
if (!rootInfo) return false
|
||||
|
||||
let ancestor = path
|
||||
while (true) {
|
||||
const ancestorInfo = await statIfPresent(ancestor)
|
||||
if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true
|
||||
const parent = dirname(ancestor)
|
||||
if (parent === ancestor) return false
|
||||
ancestor = parent
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@
|
||||
* @module @deepseek-ai/dsh-fs-sandbox
|
||||
*/
|
||||
|
||||
import { sep } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -39,6 +38,7 @@ import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent,
|
||||
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { isPathUnder } from './containment.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
|
||||
@@ -48,13 +48,6 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/** Whether `path` is `root` itself or lies beneath it (both already canonical). */
|
||||
function isUnder(path: string, root: string): boolean {
|
||||
if (path === root) return true
|
||||
const prefix = root.endsWith(sep) ? root : root + sep
|
||||
return path.startsWith(prefix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
|
||||
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
|
||||
@@ -147,7 +140,14 @@ export class SandboxedFileSystem extends LocalFileSystem {
|
||||
// symlink ancestor swapped since the tool resolved this target), and the
|
||||
// mutation delegates with THIS fresh target — never the stale one.
|
||||
const fresh = await this.resolve(target.displayPath)
|
||||
if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) {
|
||||
let contained = false
|
||||
for (const root of this.writableRoots) {
|
||||
if (await isPathUnder(fresh.targetKey, root)) {
|
||||
contained = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!contained) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
|
||||
}
|
||||
return fresh
|
||||
|
||||
57
packages/fs/fs-sandbox/tests/containment.spec.ts
Normal file
57
packages/fs/fs-sandbox/tests/containment.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Containment tests for lexical canonical paths and filesystem-identity aliases.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, parse } from 'node:path'
|
||||
import { isPathUnder } from '../src/containment.ts'
|
||||
|
||||
let base: string
|
||||
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), 'dsh-fssbx-containment-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('filesystem sandbox containment', () => {
|
||||
it('accepts equal paths, descendants, and a filesystem-root boundary', async () => {
|
||||
expect(await isPathUnder(base, base)).toBe(true)
|
||||
expect(await isPathUnder(join(base, 'child'), base)).toBe(true)
|
||||
expect(await isPathUnder(base, parse(base).root)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses case-insensitive lexical comparison for Windows-style containment', async () => {
|
||||
expect(await isPathUnder(join(base.toUpperCase(), 'child'), base.toLowerCase(), false)).toBe(true)
|
||||
expect(await isPathUnder(join(base, 'case-sensitive-child'), base, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes an alias-equivalent root by filesystem identity for a missing target', async () => {
|
||||
const realRoot = join(base, 'real')
|
||||
const aliasRoot = join(base, 'alias')
|
||||
await mkdir(realRoot)
|
||||
await symlink(realRoot, aliasRoot)
|
||||
expect(await isPathUnder(join(await realpath(realRoot), 'missing', 'file.txt'), aliasRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it('denies unrelated and missing roots', async () => {
|
||||
const allowed = join(base, 'allowed')
|
||||
const outside = join(base, 'outside')
|
||||
await mkdir(allowed)
|
||||
await mkdir(outside)
|
||||
expect(await isPathUnder(join(outside, 'file.txt'), allowed)).toBe(false)
|
||||
expect(await isPathUnder(join(outside, 'file.txt'), join(base, 'missing-root'))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a regular-file path segment as a missing target, not containment', async () => {
|
||||
const allowed = join(base, 'allowed')
|
||||
const blocker = join(base, 'blocker')
|
||||
await mkdir(allowed)
|
||||
await writeFile(blocker, 'not a directory')
|
||||
expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, parse } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
@@ -167,16 +167,15 @@ describe('workspace-write containment', () => {
|
||||
})
|
||||
|
||||
describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => {
|
||||
it('grants writes anywhere: containment against `/` allows any absolute path', async () => {
|
||||
// A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's
|
||||
// separator-suffixed-root branch: `/` already ends in the separator, so the
|
||||
// prefix stays `/` and every absolute path is contained.
|
||||
it('grants writes anywhere on that volume', async () => {
|
||||
// A degenerate but valid config: the filesystem root containing the target.
|
||||
// It exercises the separator-suffixed-root branch on POSIX and Windows.
|
||||
const rootCtx = new Context()
|
||||
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' })
|
||||
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root })
|
||||
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
const rootFs = rootCtx.fs as SandboxedFileSystem
|
||||
try {
|
||||
const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root
|
||||
const path = join(base, 'anywhere.txt') // under HOME, outside temp — allowed only via the filesystem root
|
||||
await rootFs.writeText(await rootFs.resolve(path), 'anywhere')
|
||||
expect(await readFile(path, 'utf8')).toBe('anywhere')
|
||||
} finally {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { join } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
@@ -496,7 +497,7 @@ describe('glob results', () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
|
||||
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
|
||||
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
|
||||
})
|
||||
|
||||
it('validates arguments (blank pattern, blank path)', async () => {
|
||||
@@ -578,7 +579,7 @@ describe('grep results', () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') })
|
||||
expect(text(result)).toContain('deep/a.ts\nLine 2: hit')
|
||||
expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`)
|
||||
})
|
||||
|
||||
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
|
||||
@@ -688,7 +689,7 @@ describe('presentation', () => {
|
||||
|
||||
describe('helpers', () => {
|
||||
it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => {
|
||||
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts')
|
||||
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe(join('a', 'b.ts'))
|
||||
expect(toWorkdirRelative('/w', '/w')).toBe('.')
|
||||
expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts')
|
||||
expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts')
|
||||
|
||||
@@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
|
||||
## What it does
|
||||
|
||||
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
|
||||
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
|
||||
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
|
||||
- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible.
|
||||
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
@@ -36,6 +36,132 @@ interface Pending {
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one JSON-RPC message to the child stdin.
|
||||
* @param stdin - the spawned server stdin.
|
||||
* @param message - the unencoded JSON-RPC message.
|
||||
* @param done - callback that reports asynchronous stream settlement.
|
||||
*/
|
||||
export type ConnectionWriter = (
|
||||
stdin: Writable,
|
||||
message: unknown,
|
||||
done: (error?: Error | null) => void,
|
||||
) => void
|
||||
|
||||
/** Host operations used to signal a detached process tree. */
|
||||
export interface ProcessTreeOperations {
|
||||
/** Signal a POSIX process group. */
|
||||
readonly signal: (target: number, signal: NodeJS.Signals) => void
|
||||
/** Signal the direct child when POSIX group signaling is unavailable. */
|
||||
readonly killChild: (signal: NodeJS.Signals) => void
|
||||
/** Terminate a Windows process tree by root pid. */
|
||||
readonly taskkill: (pid: number) => void
|
||||
}
|
||||
|
||||
/** Narrow taskkill runner result used by the Windows process-tree adapter. */
|
||||
export interface TaskkillResult {
|
||||
/** Process exit status, or null when spawning failed. */
|
||||
readonly status: number | null
|
||||
/** Spawn failure, when the executable could not run. */
|
||||
readonly error?: Error
|
||||
}
|
||||
|
||||
/** Invoke a command synchronously for the Windows taskkill adapter. */
|
||||
export type TaskkillRunner = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { stdio: 'ignore' },
|
||||
) => TaskkillResult
|
||||
|
||||
/** Invoke the host process-signal primitive for a POSIX process group. */
|
||||
export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean
|
||||
|
||||
const processSignalRunner: ProcessSignalRunner = process.kill.bind(process)
|
||||
|
||||
/** taskkill status for "process not found": the requested process tree is already absent. */
|
||||
const TASKKILL_TREE_NOT_FOUND_STATUS = 128
|
||||
|
||||
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate one Windows process tree and wait for taskkill to finish.
|
||||
* @param pid - root process id.
|
||||
* @param run - command runner; tests inject results without requiring Windows.
|
||||
*/
|
||||
export function taskkillProcessTree(
|
||||
pid: number,
|
||||
run: TaskkillRunner = spawnSync,
|
||||
): void {
|
||||
const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return
|
||||
if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal one POSIX process group through an injectable host primitive.
|
||||
* @param target - negative process-group id.
|
||||
* @param signal - requested signal.
|
||||
* @param run - host signal runner; tests inject it without touching real processes.
|
||||
*/
|
||||
export function signalProcessGroup(
|
||||
target: number,
|
||||
signal: NodeJS.Signals,
|
||||
run: ProcessSignalRunner = processSignalRunner,
|
||||
): void {
|
||||
run(target, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a process-tree liveness probe reports exit.
|
||||
* @param isAlive - process-tree liveness probe.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @param yieldNow - event-loop yield primitive.
|
||||
* @returns `true` when the tree exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
export async function waitForTreeExit(
|
||||
isAlive: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
yieldNow: () => Promise<unknown> = yieldToEventLoop,
|
||||
): Promise<boolean> {
|
||||
while (isAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldNow()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct
|
||||
* child; Windows requires taskkill to reach the full tree.
|
||||
* @param platform - host platform.
|
||||
* @param pid - detached root process id.
|
||||
* @param signal - requested termination signal.
|
||||
* @param operations - host operations.
|
||||
*/
|
||||
export function signalProcessTree(
|
||||
platform: NodeJS.Platform,
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
operations: ProcessTreeOperations,
|
||||
): void {
|
||||
if (platform === 'win32') {
|
||||
operations.taskkill(pid)
|
||||
return
|
||||
}
|
||||
try {
|
||||
operations.signal(-pid, signal)
|
||||
} catch {
|
||||
try {
|
||||
operations.killChild(signal)
|
||||
} catch {
|
||||
// The direct child already exited; teardown remains idempotent.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A live JSON-RPC endpoint bound to one child process. */
|
||||
export class LspConnection {
|
||||
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
|
||||
@@ -50,14 +176,16 @@ export class LspConnection {
|
||||
/**
|
||||
* @param spec - how to launch the server and answer its config requests.
|
||||
* @param onServerRequest - answers a server→client request; rejects to send an error response.
|
||||
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
|
||||
*/
|
||||
constructor(
|
||||
private readonly spec: ConnectionSpec,
|
||||
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
|
||||
private readonly writer: ConnectionWriter = writeConnectionMessage,
|
||||
) {
|
||||
this.decoder = new MessageDecoder(spec.maxMessageBytes)
|
||||
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
|
||||
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
|
||||
// `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
|
||||
// while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
|
||||
this.child = spawn(spec.command, [...spec.args], {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
@@ -94,6 +222,20 @@ export class LspConnection {
|
||||
return this.stderr.toString('utf8')
|
||||
}
|
||||
|
||||
/** Whether the transport has failed even if the child close event has not arrived yet. */
|
||||
get failed(): boolean {
|
||||
return this.closeReason !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a caught error is this connection's retained fatal transport cause.
|
||||
* @param error - error caught by the instance or provider.
|
||||
* @returns `true` only when this connection produced that exact failure.
|
||||
*/
|
||||
failedWith(error: unknown): boolean {
|
||||
return this.closeReason === error
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and await its result.
|
||||
* @param method - the JSON-RPC method.
|
||||
@@ -147,50 +289,38 @@ export class LspConnection {
|
||||
return this.nextId
|
||||
}
|
||||
|
||||
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
|
||||
/** Request termination of the server's process tree. */
|
||||
terminate(): void {
|
||||
this.signalGroup('SIGTERM')
|
||||
this.signalTree('SIGTERM')
|
||||
}
|
||||
|
||||
/** Send SIGKILL to the server's process group. */
|
||||
/** Force termination of the server's process tree. */
|
||||
kill(): void {
|
||||
this.signalGroup('SIGKILL')
|
||||
this.signalTree('SIGKILL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the owned process group has no members.
|
||||
* Wait until the owned process tree has exited.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @returns `true` when the group exited, or `false` when the signal aborted first.
|
||||
* @returns `true` when the tree exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
|
||||
while (this.processGroupAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
return true
|
||||
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
|
||||
* direct child if the group send fails. Never throws — teardown races process exit.
|
||||
*/
|
||||
private signalGroup(sig: NodeJS.Signals): void {
|
||||
/** Signal the whole process tree. */
|
||||
private signalTree(sig: NodeJS.Signals): void {
|
||||
const pid = this.child.pid
|
||||
if (pid === undefined) return
|
||||
try {
|
||||
process.kill(-pid, sig)
|
||||
} catch {
|
||||
// The group is gone (already exited) or could not be signalled; try the direct child.
|
||||
try {
|
||||
this.child.kill(sig)
|
||||
} catch {
|
||||
// Already dead; nothing to signal.
|
||||
}
|
||||
}
|
||||
signalProcessTree(process.platform, pid, sig, {
|
||||
signal: signalProcessGroup,
|
||||
killChild: this.child.kill.bind(this.child),
|
||||
taskkill: taskkillProcessTree,
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the detached process group still has at least one member. */
|
||||
private processGroupAlive(): boolean {
|
||||
/** Whether the detached tree's root or POSIX process group is still alive. */
|
||||
private processTreeAlive(): boolean {
|
||||
const pid = this.child.pid
|
||||
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
|
||||
if (pid === undefined) return false
|
||||
@@ -218,7 +348,7 @@ export class LspConnection {
|
||||
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
|
||||
// SIGKILL the whole group so helper processes don't outlive the leader.
|
||||
this.fail(asError(error))
|
||||
this.signalGroup('SIGKILL')
|
||||
this.signalTree('SIGKILL')
|
||||
return
|
||||
}
|
||||
for (const message of messages) this.dispatch(message)
|
||||
@@ -293,7 +423,7 @@ export class LspConnection {
|
||||
reject(error)
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(encodeMessage(message), done)
|
||||
this.writer(this.child.stdin, message, done)
|
||||
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
|
||||
nonconforming Writable implementation throwing synchronously. */
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
|
||||
* of server commands and registers one isolated provider for each entry. Every provider lazily
|
||||
* single-flights one server process per canonical workspace realpath, serves transient-open queries
|
||||
* through it, and evicts a crashed process so a later query can replace it. Providers read sources
|
||||
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no
|
||||
* sandbox confinement.
|
||||
* through it, and replaces a selected transport that fails before or during the next read-only
|
||||
* query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
|
||||
* and trust their configured servers — no sandbox confinement.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
||||
* unregisters from `ctx.lsp` and tears down every live server.
|
||||
@@ -221,15 +221,23 @@ class LocalLspProvider implements LspProvider {
|
||||
// synchronous get-or-create so every spawned process remains owned by teardown.
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
if (instance.dead) {
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
instance = this.instanceFor(workspace)
|
||||
}
|
||||
try {
|
||||
return await instance.query(request, source, signal)
|
||||
} catch (error) {
|
||||
// A selected child can have died while idle or fail during the next write. Queries are
|
||||
// read-only, so replace that transport once and retry transparently.
|
||||
if (!instance.isTransportFailure(error)) throw error
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
this.assertActive(signal)
|
||||
instance = this.instanceFor(workspace)
|
||||
return await instance.query(request, source, signal)
|
||||
} finally {
|
||||
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
|
||||
if (instance.dead) this.evictIfCurrent(workspace, instance)
|
||||
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
|
||||
if (instance.dead) {
|
||||
await instance.dispose()
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { abortable, abortError } from './abort.ts'
|
||||
import { LspConnection } from './connection.ts'
|
||||
import type { ConnectionSpec } from './connection.ts'
|
||||
import type { ConnectionSpec, ConnectionWriter } from './connection.ts'
|
||||
import type { HostSource } from './host.ts'
|
||||
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
|
||||
import {
|
||||
@@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec {
|
||||
readonly killGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a process tree only when graceful termination did not make it exit.
|
||||
* @param treeExited - whether the tree exited within its grace period.
|
||||
* @param forceKill - forceful process-tree termination primitive.
|
||||
*/
|
||||
export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void {
|
||||
if (!treeExited) forceKill()
|
||||
}
|
||||
|
||||
/**
|
||||
* A single initialized server process. Not exported as a provider — the provider single-flights and
|
||||
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
|
||||
@@ -58,9 +67,10 @@ export class LspInstance {
|
||||
|
||||
/**
|
||||
* @param spec - the launch, initialize, and teardown parameters.
|
||||
* @param writer - optional connection writer used by transport conformance tests.
|
||||
*/
|
||||
constructor(private readonly spec: InstanceSpec) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
|
||||
constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
|
||||
this.ready = this.initialize()
|
||||
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
|
||||
// it; queries attach the real handler.
|
||||
@@ -70,7 +80,16 @@ export class LspInstance {
|
||||
|
||||
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
|
||||
get dead(): boolean {
|
||||
return this.processClosed || this.disposed
|
||||
return this.processClosed || this.disposed || this.connection.failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a caught query error came from this instance's transport.
|
||||
* @param error - error caught by the provider.
|
||||
* @returns `true` only for the connection's retained fatal transport cause.
|
||||
*/
|
||||
isTransportFailure(error: unknown): boolean {
|
||||
return this.connection.failedWith(error)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +103,12 @@ export class LspInstance {
|
||||
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
|
||||
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
|
||||
// rather than block on the shared tail forever.
|
||||
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
|
||||
const run = abortable(this.queue, signal)
|
||||
.then(() => this.runQuery(request, source, signal))
|
||||
.catch(async (error: unknown) => {
|
||||
if (this.isTransportFailure(error)) await this.startTeardown()
|
||||
throw error
|
||||
})
|
||||
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
|
||||
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
|
||||
// on the wait does not deserialize the queue.
|
||||
@@ -272,7 +296,7 @@ export class LspInstance {
|
||||
try {
|
||||
await this.gracefulShutdown(shutdownDeadline.signal)
|
||||
} catch {
|
||||
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
|
||||
// Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
|
||||
} finally {
|
||||
shutdownDeadline[Symbol.dispose]()
|
||||
}
|
||||
@@ -286,20 +310,20 @@ export class LspInstance {
|
||||
await abortable(this.connection.closed, signal)
|
||||
}
|
||||
|
||||
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
/** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
private async forceTerminate(): Promise<void> {
|
||||
this.connection.terminate()
|
||||
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
|
||||
let groupExited: boolean
|
||||
let treeExited: boolean
|
||||
try {
|
||||
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
|
||||
treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
|
||||
} finally {
|
||||
graceDeadline[Symbol.dispose]()
|
||||
}
|
||||
if (!groupExited) this.connection.kill()
|
||||
escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
|
||||
await Promise.all([
|
||||
this.connection.closed,
|
||||
this.connection.waitForProcessGroupExit(),
|
||||
this.connection.waitForProcessTreeExit(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
|
||||
import {
|
||||
signalProcessGroup,
|
||||
signalProcessTree,
|
||||
taskkillProcessTree,
|
||||
waitForTreeExit,
|
||||
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
import type {
|
||||
ConnectionWriter,
|
||||
ProcessSignalRunner,
|
||||
ProcessTreeOperations,
|
||||
TaskkillRunner,
|
||||
} from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
@@ -53,6 +65,12 @@ describe('LspConnection', () => {
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
|
||||
})
|
||||
|
||||
it('treats signaling an already-closed child as a teardown race', async () => {
|
||||
const conn = connectScript('')
|
||||
await conn.closed
|
||||
expect(() => { conn.kill() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('answers a server workspace/configuration request from static config', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
@@ -125,7 +143,7 @@ describe('LspConnection', () => {
|
||||
})
|
||||
|
||||
/** Spawn a raw connection running an inline node script as the "server". */
|
||||
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
|
||||
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
@@ -134,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
}, () => Promise.resolve(null), writer)
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
@@ -209,13 +227,13 @@ describe('LspConnection edge behavior', () => {
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
|
||||
const conn = connectScript('require("node:fs").closeSync(0); process.stderr.write("stdin closed"); setInterval(()=>{}, 1000)')
|
||||
await waitFor(() => conn.stderrTail === 'stdin closed')
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
|
||||
})
|
||||
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
|
||||
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
|
||||
const failure = new Error('fixture stdin failure')
|
||||
const writer: ConnectionWriter = (_stdin, _message, done) => {
|
||||
queueMicrotask(() => { done(failure) })
|
||||
}
|
||||
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
@@ -230,6 +248,72 @@ describe('LspConnection edge behavior', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('process-tree signaling', () => {
|
||||
it('forwards POSIX process-group signals through the host runner', () => {
|
||||
const run: ProcessSignalRunner = vi.fn(() => true)
|
||||
signalProcessGroup(-42, 'SIGKILL', run)
|
||||
expect(run).toHaveBeenCalledWith(-42, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('waits for tree exit and stops when its bound aborts', async () => {
|
||||
const isAlive = vi.fn()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false)
|
||||
const yieldNow = vi.fn(() => Promise.resolve())
|
||||
await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true)
|
||||
expect(yieldNow).toHaveBeenCalledOnce()
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => {
|
||||
const operations = fakeProcessTreeOperations()
|
||||
signalProcessTree('win32', 42, 'SIGTERM', operations)
|
||||
expect(operations.taskkill).toHaveBeenCalledWith(42)
|
||||
expect(operations.signal).not.toHaveBeenCalled()
|
||||
|
||||
signalProcessTree('linux', 42, 'SIGKILL', operations)
|
||||
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
|
||||
})
|
||||
|
||||
it('surfaces a Windows taskkill failure without downgrading to the direct child', () => {
|
||||
const fallback = fakeProcessTreeOperations()
|
||||
vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') })
|
||||
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
|
||||
expect(fallback.killChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => {
|
||||
const posixGone = fakeProcessTreeOperations()
|
||||
vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') })
|
||||
vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') })
|
||||
expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => {
|
||||
const success: TaskkillRunner = vi.fn(() => ({ status: 0 }))
|
||||
taskkillProcessTree(42, success)
|
||||
expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' })
|
||||
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow()
|
||||
|
||||
const spawnFailure = new Error('cannot spawn taskkill')
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure)
|
||||
expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/)
|
||||
})
|
||||
})
|
||||
|
||||
/** Create observable process-tree operations without touching host processes. */
|
||||
function fakeProcessTreeOperations(): ProcessTreeOperations {
|
||||
return {
|
||||
signal: vi.fn(),
|
||||
killChild: vi.fn(),
|
||||
taskkill: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll a predicate until it holds or a deadline elapses. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now()
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
@@ -28,7 +26,7 @@
|
||||
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
|
||||
*/
|
||||
|
||||
import { appendFileSync, closeSync } from 'node:fs'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
@@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
|
||||
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
@@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
if (method === 'initialized') {
|
||||
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
|
||||
if (pauseStdinAfterInitialized) process.stdin.pause()
|
||||
if (closeStdinAfterInitialized) closeSync(0)
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
const reply = (): void => {
|
||||
if (closeStdinAfterReply) closeSync(0)
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
@@ -202,6 +196,6 @@ function send(message: Record<string, unknown>): void {
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
|
||||
if (pauseStdinAfterInitialized) {
|
||||
setInterval(() => {}, 1000)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ describe('readHostSource', () => {
|
||||
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
|
||||
it.skipIf(process.platform === 'win32')('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
const fifo = join(ws, 'pipe.ts')
|
||||
await execFileAsync('mkfifo', [fifo])
|
||||
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
import { encodeMessage } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts'
|
||||
import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
@@ -26,7 +29,11 @@ afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
function makeInstance(
|
||||
env: Record<string, string> = {},
|
||||
overrides: Partial<InstanceSpec> = {},
|
||||
writer?: ConnectionWriter,
|
||||
): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
@@ -39,7 +46,7 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
})
|
||||
}, writer)
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
@@ -201,17 +208,25 @@ describe('LspInstance query and abort', () => {
|
||||
})
|
||||
|
||||
it('terminates when stdin fails during the didOpen write', async () => {
|
||||
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
|
||||
// the instance must still become dead so its provider can replace it.
|
||||
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
||||
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
})
|
||||
}, failingWriter('textDocument/didOpen'))
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('awaits process exit before rejecting a request write failure', async () => {
|
||||
const instance = makeInstance({}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
}, failingWriter('textDocument/definition'))
|
||||
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
|
||||
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
|
||||
expect(processAlive(pid)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
|
||||
@@ -228,8 +243,7 @@ describe('LspInstance query and abort', () => {
|
||||
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
|
||||
kind: 'locations',
|
||||
locations: [],
|
||||
@@ -240,6 +254,14 @@ describe('LspInstance query and abort', () => {
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('escalates only when the process tree survives its grace period', () => {
|
||||
const forceKill = vi.fn()
|
||||
escalateProcessTree(false, forceKill)
|
||||
expect(forceKill).toHaveBeenCalledOnce()
|
||||
escalateProcessTree(true, forceKill)
|
||||
expect(forceKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets a server finish protocol exit before signal escalation', async () => {
|
||||
const marker = join(root, 'graceful-exit.log')
|
||||
const instance = makeInstance({
|
||||
@@ -281,7 +303,7 @@ describe('LspInstance disposal', () => {
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
|
||||
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
|
||||
const marker = join(root, 'helper.pid')
|
||||
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
|
||||
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
|
||||
@@ -298,6 +320,7 @@ describe('LspInstance disposal', () => {
|
||||
await first
|
||||
} finally {
|
||||
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
|
||||
await waitForProcessExit(helperPid)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -322,6 +345,26 @@ function processAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */
|
||||
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (processAlive(pid)) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
/** Write normally except for one method whose callback receives a deterministic transport error. */
|
||||
function failingWriter(method: string): ConnectionWriter {
|
||||
return (stdin, message, done) => {
|
||||
if ((message as { method?: unknown }).method === method) {
|
||||
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
|
||||
return
|
||||
}
|
||||
stdin.write(encodeMessage(message), done)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
|
||||
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
|
||||
@@ -137,9 +137,15 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
|
||||
const marker = join(root, 'initialize-rejection-exit.log')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_ENCODING: 'utf-8',
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
})
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
@@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => {
|
||||
await expect(ctx.plugin(LspLocal, config('nope', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `::${join(root, 'empty')}` },
|
||||
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -116,7 +116,8 @@ describe('lsp-local provider resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
|
||||
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-tool-lsp'
|
||||
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const WS = '/home/u/proj'
|
||||
const WS = resolve('/home/u/proj')
|
||||
|
||||
function loc(uri: string, line: number, character = 0): LspLocation {
|
||||
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
|
||||
@@ -52,8 +52,9 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('returns an absolute path for a file: URI outside the workspace', () => {
|
||||
const uri = pathToFileURL('/other/lib/b.ts').href
|
||||
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
|
||||
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
|
||||
const uri = pathToFileURL(outside).href
|
||||
expect(renderUri(uri, WS)).toBe(outside)
|
||||
})
|
||||
|
||||
it('renders the workspace root itself as "."', () => {
|
||||
@@ -72,8 +73,8 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
|
||||
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
|
||||
// An encoded path separator is invalid on every platform and must remain verbatim.
|
||||
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -40,8 +42,11 @@ async function mount(
|
||||
|
||||
let seq = 0
|
||||
const testToolSignal = new AbortController().signal
|
||||
const workspaceRoot = resolve('/virtual/workspace')
|
||||
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
|
||||
const workspaceAlias = resolve('/virtual/workspace-alias')
|
||||
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: `c-${++seq}` as never,
|
||||
@@ -53,8 +58,8 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/ws',
|
||||
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: workspaceRoot,
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
@@ -107,40 +112,39 @@ describe('tool-lsp execution', () => {
|
||||
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
|
||||
const provider = stubProvider(() => okLocations)
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(provider.seen[0]).toMatchObject({
|
||||
operation: 'goToDefinition',
|
||||
filePath: 'a.ts',
|
||||
position: { line: 2, character: 4 },
|
||||
workspaceRoot: '/ws',
|
||||
workspaceRoot,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders locations relative to the workspace', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
|
||||
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
|
||||
// location URIs are under. Relativizing against the alias would misclassify the location as
|
||||
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
|
||||
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
|
||||
// Relativizing against the alias would misclassify the location as external.
|
||||
const provider = stubProvider(() => ({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/real/ws',
|
||||
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot,
|
||||
}))
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('renders hover content', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
|
||||
})
|
||||
|
||||
@@ -153,14 +157,14 @@ describe('tool-lsp execution', () => {
|
||||
|
||||
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('returns a structured INVALID_ARGS on a bad operation', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
@@ -176,7 +180,7 @@ describe('tool-lsp execution', () => {
|
||||
},
|
||||
}
|
||||
const { ctx } = await mount(provider)
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
|
||||
// undefined); the point is the tool threads it through without throwing.
|
||||
expect(seen).toHaveLength(1)
|
||||
|
||||
@@ -325,13 +325,19 @@ describe('probeTimeoutMs config', () => {
|
||||
})
|
||||
|
||||
it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => {
|
||||
// The same sleeping launcher passes under the default 5000ms budget and
|
||||
// fails under a 250ms one — the config demonstrably reaches spawnSync.
|
||||
// The same 1s launcher reads usable under a generous budget and unusable
|
||||
// under a 250ms one — the config demonstrably reaches spawnSync. Both bounds
|
||||
// keep a wide margin from the launcher's 1s runtime so a loaded host (where
|
||||
// spawnSync blocks the worker and fork/exec latency inflates wall-clock)
|
||||
// cannot flip either verdict; the vitest timeout clears the patient budget.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
|
||||
|
||||
const patient = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
|
||||
const patient = await setup(
|
||||
{ probeTimeoutMs: 15_000 },
|
||||
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
|
||||
)
|
||||
expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
|
||||
|
||||
const impatient = await setup(
|
||||
@@ -339,7 +345,7 @@ describe('probeTimeoutMs config', () => {
|
||||
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
|
||||
)
|
||||
expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe('the default seatbelt probe (sandbox-exec contract)', () => {
|
||||
|
||||
@@ -120,6 +120,7 @@ declare module 'cordis' {
|
||||
* skipped for a sole candidate, whose own refusal remains the fail-closed end.
|
||||
*/
|
||||
export abstract class SandboxProvider extends Service {
|
||||
/* v8 ignore next -- Windows has no sandbox backend to instantiate this service. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sandbox')
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class RecordingPort implements PromptPort {
|
||||
}
|
||||
}
|
||||
|
||||
describe('create-sdk terminal contract', () => {
|
||||
describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
|
||||
it('renders package-manager-specific setup commands', () => {
|
||||
const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
|
||||
expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')
|
||||
|
||||
@@ -25,7 +25,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
describe('globalConfigDir', () => {
|
||||
it('prefers an explicit DSH_HOME override', () => {
|
||||
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
|
||||
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe(resolve('/custom/dsh'))
|
||||
})
|
||||
|
||||
it('falls back to ~/.dsh when DSH_HOME is unset', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
|
||||
|
||||
## Durability and crash semantics
|
||||
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
|
||||
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
|
||||
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
|
||||
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
|
||||
@@ -62,5 +62,4 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
|
||||
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
|
||||
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
|
||||
- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated.
|
||||
- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend.
|
||||
- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive.
|
||||
- **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises'
|
||||
import { open, mkdir, readFile, readdir, link, rm, stat as fsStat, truncate } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type JsonlCompression,
|
||||
} from './format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
|
||||
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
|
||||
|
||||
export type { JsonlCompression } from './format.ts'
|
||||
|
||||
@@ -81,9 +82,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
private coordinator: PersistenceCoordinator<JsonlTornMarker>
|
||||
private rootEncodingCheck: Promise<void> | undefined
|
||||
|
||||
/** Runtime host platform used to decide whether directory sync is supported. */
|
||||
readonly internals: { platform: NodeJS.Platform } = { platform: process.platform }
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx)
|
||||
// Resolve once so later process.cwd() changes cannot split one backend across roots.
|
||||
@@ -254,32 +252,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
// --- materialization / append / repair (file mechanics) ---
|
||||
|
||||
/** Atomically write the header line + first batch (temp-write, fsync, collision-safe hard-link publish). */
|
||||
/** Atomically write the header line + first batch (temp-write, fsync, publish). */
|
||||
private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
|
||||
const dir = sessionDir(this.root, meta.cwd)
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
await this.syncDir(dirname(this.root))
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await this.syncDir(this.root)
|
||||
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
|
||||
// Materialization is the first write; an existing log is an id collision.
|
||||
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
|
||||
if (await this.exists(finalPath)) {
|
||||
throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`)
|
||||
}
|
||||
await this.rejectOppositeArtifact(meta.cwd, meta.id)
|
||||
const content = await this.encodeMaterialization(meta, events)
|
||||
|
||||
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
|
||||
const handle = await open(tmp, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(content)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
|
||||
if (process.platform === 'win32') {
|
||||
await this.materializeWin32(dir, finalPath, meta.id, content)
|
||||
} else {
|
||||
await this.materializePosix(dir, finalPath, meta.id, content)
|
||||
}
|
||||
// Publish with link()+unlink(): unlike rename(), link fails if another
|
||||
// process materialized the same id first.
|
||||
}
|
||||
|
||||
/* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
|
||||
private async materializePosix(
|
||||
dir: string,
|
||||
finalPath: string,
|
||||
id: SessionId,
|
||||
content: Buffer | string,
|
||||
): Promise<void> {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(dirname(this.root))
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(this.root)
|
||||
await this.rejectExistingLog(finalPath, id)
|
||||
const tmp = await this.writeSyncedTempFile(finalPath, content)
|
||||
// Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
|
||||
// final path already exists, so two processes materializing the same id
|
||||
// concurrently cannot clobber each other. rename() would silently overwrite.
|
||||
let linked = false
|
||||
try {
|
||||
await link(tmp, finalPath)
|
||||
@@ -290,16 +292,64 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
/* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
|
||||
if (!linked) await rm(tmp, { force: true })
|
||||
}
|
||||
// The published link becomes crash-durable only after its directory fsync.
|
||||
await this.syncDir(dir)
|
||||
// Best-effort temp cleanup: the log is already published and durable, so a failure to
|
||||
// remove the (now-redundant) temp hard link must not reject the append.
|
||||
// link() succeeded — the log is published. fsync the directory so the new
|
||||
// entry survives a power loss: the new link is not crash-durable until the
|
||||
// parent directory's metadata is synced.
|
||||
await this.syncDirPosix(dir)
|
||||
// Best-effort temp cleanup: the log is already published and durable, so a
|
||||
// failure to remove the (now-redundant) temp hard link must NOT reject the
|
||||
// append. Swallow only the rm failure; nothing else of consequence runs here.
|
||||
try {
|
||||
await rm(tmp, { force: true })
|
||||
} catch {
|
||||
/* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/* v8 ignore start -- native Windows coverage exercises this integration path */
|
||||
private async materializeWin32(
|
||||
dir: string,
|
||||
finalPath: string,
|
||||
id: SessionId,
|
||||
content: Buffer | string,
|
||||
): Promise<void> {
|
||||
await ensureDurableDirectoryWin32(this.root)
|
||||
await ensureDurableDirectoryWin32(dir)
|
||||
await this.rejectExistingLog(finalPath, id)
|
||||
const tmp = await this.writeSyncedTempFile(finalPath, content)
|
||||
try {
|
||||
await publishNewFileWin32(tmp, finalPath)
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
private async rejectExistingLog(finalPath: string, id: SessionId): Promise<void> {
|
||||
// Never publish over an existing committed log: materialize is the first
|
||||
// write of a session the backend believes is new. A file here means a
|
||||
// different session shares this id on disk — reject loudly. (createCore
|
||||
// already guards the create path, so this is unreachable-in-practice TOCTOU
|
||||
// defense.)
|
||||
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
|
||||
if (await this.exists(finalPath)) {
|
||||
throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`)
|
||||
}
|
||||
}
|
||||
|
||||
private async writeSyncedTempFile(finalPath: string, content: Buffer | string): Promise<string> {
|
||||
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
|
||||
const handle = await open(tmp, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(content)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
/** Encode the header and first batch without combining their frame boundaries. */
|
||||
private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
|
||||
@@ -317,22 +367,17 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
return this.compression === 'zstd' ? compressZstdFrame(body) : body
|
||||
}
|
||||
|
||||
/** fsync a directory when the host exposes that durability primitive. */
|
||||
private async syncDir(dir: string): Promise<void> {
|
||||
/** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
|
||||
/* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
|
||||
private async syncDirPosix(dir: string): Promise<void> {
|
||||
const handle = await open(dir, 'r')
|
||||
try {
|
||||
try {
|
||||
await handle.sync()
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
// Node opens directories on Windows but its fsync binding rejects them.
|
||||
// File-content fsync remains mandatory; only this unsupported primitive is skipped.
|
||||
if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error
|
||||
}
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/**
|
||||
* Append and fsync event lines. On a partial write or sync failure, restore the
|
||||
@@ -343,17 +388,37 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
const content = await this.encodeEventBatch(events)
|
||||
const path = logPath(this.root, meta.cwd, meta.id, this.compression)
|
||||
const handle = await open(path, 'a')
|
||||
let closed = false
|
||||
const closeAppendHandle = async (): Promise<void> => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
await handle.close()
|
||||
}
|
||||
|
||||
try {
|
||||
const { size: before } = await handle.stat()
|
||||
try {
|
||||
await handle.writeFile(content)
|
||||
await handle.sync()
|
||||
} catch (error) {
|
||||
// Roll back whatever bytes landed so a retry starts from a clean EOF.
|
||||
await handle.truncate(before)
|
||||
await handle.sync()
|
||||
try {
|
||||
await closeAppendHandle()
|
||||
await this.rollbackAppend(path, before)
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
await closeAppendHandle()
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackAppend(path: string, size: number): Promise<void> {
|
||||
const handle = await open(path, 'r+')
|
||||
try {
|
||||
await handle.truncate(size)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -505,13 +570,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
await handle.close()
|
||||
return true
|
||||
} catch (error) {
|
||||
// Only ENOENT means absent. A permission/I/O error must surface, not be
|
||||
// collapsed to `false` — otherwise load() reports "not found" and collision
|
||||
// checks proceed under a false absence assumption.
|
||||
if (isENOENT(error)) return false
|
||||
// Only ENOENT means absent. A permission/I/O error must surface rather
|
||||
// than letting load or collision checks proceed under false absence.
|
||||
// Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
|
||||
// the immediate parent so a blocked cwd bucket remains a storage fault.
|
||||
/* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
|
||||
if (isENOENT(error)) {
|
||||
await this.assertLogParentAllowsAbsence(path)
|
||||
return false
|
||||
}
|
||||
/* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
|
||||
private async assertLogParentAllowsAbsence(path: string): Promise<void> {
|
||||
try {
|
||||
const parent = dirname(path)
|
||||
const info = await fsStat(parent)
|
||||
if (info.isDirectory()) return
|
||||
const error = new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`) as NodeJS.ErrnoException
|
||||
error.code = 'ENOTDIR'
|
||||
error.path = parent
|
||||
throw error
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
export default SessionPersistenceJsonl
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Windows durable namespace helpers for the JSONL backend.
|
||||
*
|
||||
* POSIX publishes a newly-created log by creating a directory entry and then
|
||||
* fsyncing the parent directory. Windows does not expose that parent-directory
|
||||
* fsync contract through Node, so the Windows path uses the native durable
|
||||
* namespace primitive instead: create a staging object in the target directory
|
||||
* and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
|
||||
* replacement or cross-volume copy fallback.
|
||||
*
|
||||
* @module dsh-session-persistence-jsonl/win32
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
|
||||
|
||||
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
|
||||
type GetLastError = () => number
|
||||
|
||||
interface Win32Bindings {
|
||||
moveFileExW: MoveFileExW
|
||||
getLastError: GetLastError
|
||||
}
|
||||
|
||||
interface Win32ErrnoException extends NodeJS.ErrnoException {
|
||||
win32Code: number
|
||||
dest: string
|
||||
}
|
||||
|
||||
const MOVEFILE_WRITE_THROUGH = 0x00000008
|
||||
const ERROR_FILE_NOT_FOUND = 2
|
||||
const ERROR_PATH_NOT_FOUND = 3
|
||||
const ERROR_ACCESS_DENIED = 5
|
||||
const ERROR_NOT_SAME_DEVICE = 17
|
||||
const ERROR_FILE_EXISTS = 80
|
||||
const ERROR_INVALID_NAME = 123
|
||||
const ERROR_ALREADY_EXISTS = 183
|
||||
|
||||
let bindings: Win32Bindings | undefined
|
||||
|
||||
/** Load the small Win32 surface lazily so non-Windows processes never load Koffi. */
|
||||
async function win32(): Promise<Win32Bindings> {
|
||||
if (bindings !== undefined) return bindings
|
||||
const koffi = (await import('koffi')).default
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
bindings = {
|
||||
moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW,
|
||||
getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError,
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
function errnoCode(win32Code: number): string {
|
||||
switch (win32Code) {
|
||||
case ERROR_FILE_NOT_FOUND:
|
||||
case ERROR_PATH_NOT_FOUND:
|
||||
return 'ENOENT'
|
||||
case ERROR_ACCESS_DENIED:
|
||||
return 'EACCES'
|
||||
case ERROR_NOT_SAME_DEVICE:
|
||||
return 'EXDEV'
|
||||
case ERROR_FILE_EXISTS:
|
||||
case ERROR_ALREADY_EXISTS:
|
||||
return 'EEXIST'
|
||||
case ERROR_INVALID_NAME:
|
||||
return 'EINVAL'
|
||||
default:
|
||||
return 'EIO'
|
||||
}
|
||||
}
|
||||
|
||||
function win32Error(syscall: string, win32Code: number, path: string, dest: string): Win32ErrnoException {
|
||||
const code = errnoCode(win32Code)
|
||||
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`) as Win32ErrnoException
|
||||
error.code = code
|
||||
error.errno = win32Code
|
||||
error.syscall = syscall
|
||||
error.path = path
|
||||
error.dest = dest
|
||||
error.win32Code = win32Code
|
||||
return error
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
async function assertDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
const info = await stat(path)
|
||||
if (info.isDirectory()) return true
|
||||
const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException
|
||||
error.code = 'ENOTDIR'
|
||||
error.path = path
|
||||
throw error
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish `existing` at `replacement` with Windows write-through rename
|
||||
* semantics. The destination must not already exist; the move must stay within
|
||||
* the volume (no copy fallback flag is set).
|
||||
* @param existing - the synced staging path to move.
|
||||
* @param replacement - the final path, which must not already exist.
|
||||
*/
|
||||
export async function publishNewFileWin32(existing: string, replacement: string): Promise<void> {
|
||||
const api = await win32()
|
||||
const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH)
|
||||
if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `target` and its missing ancestors with durable Windows namespace
|
||||
* publication. Each missing directory is first created as a random staging
|
||||
* sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
|
||||
* with another creator are accepted only after verifying the winner is a
|
||||
* directory.
|
||||
* @param target - the absolute directory path to create durably when absent.
|
||||
*/
|
||||
export async function ensureDurableDirectoryWin32(target: string): Promise<void> {
|
||||
const absolute = resolve(target)
|
||||
const root = parse(absolute).root
|
||||
await assertDirectory(root)
|
||||
|
||||
const segments = absolute.slice(root.length).split(/[\\/]+/).filter(part => part.length > 0)
|
||||
let current = root
|
||||
for (const segment of segments) {
|
||||
const next = join(current, segment)
|
||||
if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next)
|
||||
current = next
|
||||
}
|
||||
}
|
||||
|
||||
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
|
||||
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
|
||||
try {
|
||||
await publishNewFileWin32(staging, target)
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
if (isEEXIST(error) && await assertDirectory(target)) return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -47,21 +46,6 @@ afterEach(async () => {
|
||||
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function rejectDirectorySync(code: string): Promise<void> {
|
||||
const handle = await open(root, 'r')
|
||||
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
|
||||
await handle.close()
|
||||
const realSync = proto.sync
|
||||
vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
|
||||
if ((await this.stat()).isDirectory()) {
|
||||
const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
|
||||
error.code = code
|
||||
throw error
|
||||
}
|
||||
return realSync.call(this)
|
||||
})
|
||||
}
|
||||
|
||||
function appendClosedTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
@@ -358,26 +342,43 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
|
||||
await rejectDirectorySync('EPERM')
|
||||
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
|
||||
backend.internals.platform = 'win32'
|
||||
const m = meta('windows-directory-sync')
|
||||
it('reports both the append failure and a failed rollback', async () => {
|
||||
const m = meta('rollback-failure')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
|
||||
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
|
||||
})
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
|
||||
it.each([
|
||||
['linux', 'EPERM'],
|
||||
['win32', 'EIO'],
|
||||
] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
|
||||
await rejectDirectorySync(code)
|
||||
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
|
||||
backend.internals.platform = platform
|
||||
const m = meta(`directory-sync-${platform}-${code}`)
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
|
||||
const path = rawLogPath(root, undefined, m.id)
|
||||
const handle = await (await import('node:fs/promises')).open(path, 'r')
|
||||
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
|
||||
await handle.close()
|
||||
const realSync = proto.sync
|
||||
let failed = false
|
||||
const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
|
||||
if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
|
||||
return realSync.call(this)
|
||||
})
|
||||
const backend = ctx.sessionPersistence as unknown as {
|
||||
rollbackAppend: (path: string, size: number) => Promise<void>
|
||||
}
|
||||
const realRollback = backend.rollbackAppend.bind(backend)
|
||||
backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
|
||||
|
||||
try {
|
||||
await ctx.sessionPersistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
] as SessionEvent[])
|
||||
throw new Error('expected append to reject')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AggregateError)
|
||||
const aggregate = error as AggregateError
|
||||
expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
|
||||
expect(aggregate.errors).toHaveLength(2)
|
||||
expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
|
||||
expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
|
||||
} finally {
|
||||
backend.rollbackAppend = realRollback
|
||||
syncSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Unit tests for the Windows durable namespace helper with a mocked kernel32
|
||||
* binding. The real JSONL suite exercises the helper on native Windows; these
|
||||
* tests keep the Win32 error mapping and race handling covered on every host.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const MOVEFILE_WRITE_THROUGH = 0x00000008
|
||||
const ERROR_FILE_NOT_FOUND = 2
|
||||
const ERROR_PATH_NOT_FOUND = 3
|
||||
const ERROR_ACCESS_DENIED = 5
|
||||
const ERROR_NOT_SAME_DEVICE = 17
|
||||
const ERROR_FILE_EXISTS = 80
|
||||
const ERROR_INVALID_NAME = 123
|
||||
const ERROR_ALREADY_EXISTS = 183
|
||||
|
||||
type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => number
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
function stripNamespace(path: string): string {
|
||||
if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice('\\\\?\\UNC\\'.length)}`
|
||||
if (path.startsWith('\\\\?\\')) return path.slice('\\\\?\\'.length)
|
||||
return path
|
||||
}
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-win32-'))
|
||||
roots.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function importWithMove(moveFileExW: MoveFileExW): Promise<typeof import('../src/win32.ts')> {
|
||||
vi.resetModules()
|
||||
vi.doMock('koffi', () => {
|
||||
let lastError = 0
|
||||
const setLastError = (code: number): void => { lastError = code }
|
||||
const move: MoveFileExW = (existing, replacement, flags, setError) => {
|
||||
const ok = moveFileExW(existing, replacement, flags, setError)
|
||||
lastError = ok === 0 ? lastError : 0
|
||||
return ok
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
load: () => ({
|
||||
func: (_convention: string, name: string, result: string) => {
|
||||
if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => {
|
||||
expect(result).toBe('int')
|
||||
const ok = move(existing, replacement, flags, setLastError)
|
||||
return ok
|
||||
}
|
||||
return () => lastError
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
return import('../src/win32.ts')
|
||||
}
|
||||
|
||||
async function importWithError(code: number): Promise<typeof import('../src/win32.ts')> {
|
||||
vi.resetModules()
|
||||
vi.doMock('koffi', () => ({
|
||||
default: {
|
||||
load: () => ({
|
||||
func: (_convention: string, name: string) => {
|
||||
if (name === 'MoveFileExW') return () => 0
|
||||
return () => code
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
return import('../src/win32.ts')
|
||||
}
|
||||
|
||||
async function importWithFilesystemMove(): Promise<typeof import('../src/win32.ts')> {
|
||||
return importWithMove((existing, replacement, flags, setLastError) => {
|
||||
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
|
||||
const from = stripNamespace(existing)
|
||||
const to = stripNamespace(replacement)
|
||||
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
|
||||
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
|
||||
renameSync(from, to)
|
||||
return 1
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.doUnmock('koffi')
|
||||
vi.resetModules()
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Windows durable namespace helpers', () => {
|
||||
it('publishes a new file with write-through MoveFileExW semantics', async () => {
|
||||
const { publishNewFileWin32 } = await importWithFilesystemMove()
|
||||
const root = await tempRoot()
|
||||
const tmp = join(root, 'log.tmp')
|
||||
const final = join(root, 'log.jsonl')
|
||||
await writeFile(tmp, 'content')
|
||||
|
||||
await publishNewFileWin32(tmp, final)
|
||||
expect(existsSync(tmp)).toBe(false)
|
||||
expect(readFileSync(final, 'utf8')).toBe('content')
|
||||
})
|
||||
|
||||
it('maps Win32 publish failures to Node-style errno codes', async () => {
|
||||
const cases = [
|
||||
[ERROR_FILE_NOT_FOUND, 'ENOENT'],
|
||||
[ERROR_PATH_NOT_FOUND, 'ENOENT'],
|
||||
[ERROR_ACCESS_DENIED, 'EACCES'],
|
||||
[ERROR_NOT_SAME_DEVICE, 'EXDEV'],
|
||||
[ERROR_FILE_EXISTS, 'EEXIST'],
|
||||
[ERROR_ALREADY_EXISTS, 'EEXIST'],
|
||||
[ERROR_INVALID_NAME, 'EINVAL'],
|
||||
[9999, 'EIO'],
|
||||
] as const
|
||||
for (const [win32Code, code] of cases) {
|
||||
const { publishNewFileWin32 } = await importWithError(win32Code)
|
||||
await expect(publishNewFileWin32('from', 'to')).rejects.toMatchObject({ code, win32Code, path: 'from', dest: 'to' })
|
||||
}
|
||||
})
|
||||
|
||||
it('creates missing directories through staging siblings and tolerates an already-created race', async () => {
|
||||
const root = await tempRoot()
|
||||
const raced = join(root, 'raced')
|
||||
const { ensureDurableDirectoryWin32 } = await importWithMove((existing, replacement, flags, setLastError) => {
|
||||
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
|
||||
const from = stripNamespace(existing)
|
||||
const to = stripNamespace(replacement)
|
||||
if (to === raced) {
|
||||
mkdirSync(to)
|
||||
setLastError(ERROR_ALREADY_EXISTS)
|
||||
return 0
|
||||
}
|
||||
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
|
||||
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
|
||||
renameSync(from, to)
|
||||
return 1
|
||||
})
|
||||
|
||||
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
|
||||
expect(existsSync(join(root, 'a', 'b'))).toBe(true)
|
||||
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
|
||||
await ensureDurableDirectoryWin32(raced)
|
||||
expect(existsSync(raced)).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces directory publication failures other than an existing-target race', async () => {
|
||||
const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED)
|
||||
const root = await tempRoot()
|
||||
|
||||
await expect(ensureDurableDirectoryWin32(join(root, 'denied'))).rejects.toMatchObject({ code: 'EACCES' })
|
||||
})
|
||||
|
||||
it('rejects a non-directory component instead of treating it as missing', async () => {
|
||||
const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
|
||||
const root = await tempRoot()
|
||||
const blocked = join(root, 'blocked')
|
||||
writeFileSync(blocked, 'x')
|
||||
|
||||
await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
|
||||
})
|
||||
})
|
||||
@@ -476,7 +476,9 @@ describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
const walPath = await freshDbPath()
|
||||
const bWal = await backend(walPath)
|
||||
await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
|
||||
expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
|
||||
const probe = openDatabase(walPath, 'wal')
|
||||
expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
|
||||
probe.close()
|
||||
await bWal.dispose()
|
||||
|
||||
const deletePath = await freshDbPath()
|
||||
|
||||
@@ -316,7 +316,9 @@ async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean;
|
||||
try {
|
||||
const info = await stat(fullPath)
|
||||
if (info.isDirectory()) return 'directory'
|
||||
/* v8 ignore else -- the special-file symlink branch relies on POSIX /dev/null. */
|
||||
if (info.isFile()) return 'file'
|
||||
/* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */
|
||||
return undefined
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { basename, dirname, isAbsolute, join, normalize } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SaveTextSpill } from '@deepseek-ai/dsh-spill'
|
||||
@@ -63,7 +63,8 @@ describe('sessionDir', () => {
|
||||
it('is a stable per-session hash under the root', () => {
|
||||
const dir = sessionDir('/spill', 'sess-1')
|
||||
expect(dir).toBe(sessionDir('/spill', 'sess-1'))
|
||||
expect(dir).toMatch(/\/spill\/session-[0-9a-f]{12}$/)
|
||||
expect(dirname(dir)).toBe(normalize('/spill'))
|
||||
expect(basename(dir)).toMatch(/^session-[0-9a-f]{12}$/)
|
||||
expect(sessionDir('/spill', 'sess-2')).not.toBe(dir)
|
||||
})
|
||||
})
|
||||
@@ -74,7 +75,7 @@ describe('saveTextFile', () => {
|
||||
expect(readFileSync(saved.path, 'utf8')).toBe('héllo')
|
||||
expect(saved.bytes).toBe(Buffer.byteLength('héllo', 'utf8'))
|
||||
expect(dirname(saved.path)).toBe(sessionDir(root, 'sess-1'))
|
||||
expect(saved.path).toMatch(/\/[0-9a-f]{12}-r\.txt$/)
|
||||
expect(basename(saved.path)).toMatch(/^[0-9a-f]{12}-r\.txt$/)
|
||||
})
|
||||
|
||||
it('sanitizes a traversal-shaped suggested name into one segment', async () => {
|
||||
@@ -84,11 +85,16 @@ describe('saveTextFile', () => {
|
||||
expect(saved.path.includes('/..')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates the session dir with owner-only permissions', async () => {
|
||||
it('creates the session directory and file with owner-only POSIX permissions', async () => {
|
||||
const saved = await saveTextFile({ root, sessionId: 'sess-1', suggestedName: 'r.txt', content: 'x' })
|
||||
// 0o700 dir, 0o600 file (masked by umask, but the owner bits must hold).
|
||||
expect(statSync(dirname(saved.path)).mode & 0o700).toBe(0o700)
|
||||
expect(statSync(saved.path).mode & 0o600).toBe(0o600)
|
||||
const directory = statSync(dirname(saved.path))
|
||||
const file = statSync(saved.path)
|
||||
expect(directory.isDirectory()).toBe(true)
|
||||
expect(file.isFile()).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(directory.mode & 0o777).toBe(0o700)
|
||||
expect(file.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives distinct paths to two saves of the same name', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ The returned run id is minted in the parent namespace. The child server's sessio
|
||||
|
||||
After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation.
|
||||
|
||||
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented.
|
||||
`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented.
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
@@ -28,8 +28,8 @@ ACP advertises no start-time capabilities because this process cannot enforce th
|
||||
| `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. |
|
||||
| `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. |
|
||||
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. |
|
||||
| `disposeGraceMs` | `3000` | Grace after SIGTERM before SIGKILL. |
|
||||
| `disposeEofGraceMs` | `6000` | Grace after stdin EOF before platform termination. |
|
||||
| `disposeGraceMs` | `3000` | Exit-confirmation grace after termination; POSIX also waits this long after SIGTERM before SIGKILL. |
|
||||
|
||||
```yaml
|
||||
- id: subagent-acp
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface Config {
|
||||
* before the parent escalates to a signal.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
|
||||
/** Termination confirmation window (ms), including forced exit on every platform. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ export interface AcpRunSpec {
|
||||
*/
|
||||
disposeEofGraceMs: number
|
||||
/**
|
||||
* Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in
|
||||
* {@link SubagentRun.dispose}. The plugin fills this from its
|
||||
* `disposeGraceMs` config.
|
||||
* Termination confirmation window (ms) in {@link SubagentRun.dispose}; POSIX applies it after
|
||||
* `SIGTERM` and `SIGKILL`, while Windows applies it after direct forced termination. The plugin
|
||||
* fills this from its `disposeGraceMs` config.
|
||||
*/
|
||||
disposeGraceMs: number
|
||||
/**
|
||||
@@ -79,7 +79,7 @@ export interface AcpRunSpec {
|
||||
/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */
|
||||
export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000
|
||||
|
||||
/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */
|
||||
/** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/**
|
||||
@@ -304,9 +304,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
if (disposal !== undefined) return disposal
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
requestCancel()
|
||||
// The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces
|
||||
// from stdin EOF, including the final flush, so this backend uses a wider
|
||||
// EOF grace before signals escalate.
|
||||
// The shared platform-aware ladder awaits exit. ACP normally quiesces from
|
||||
// stdin EOF, including the final flush, so this backend uses a wider EOF
|
||||
// grace before process termination escalates.
|
||||
disposal = disposeProcess()
|
||||
return disposal
|
||||
},
|
||||
|
||||
@@ -215,7 +215,8 @@ describe('cwd resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a config cwd directory without search permission at load', async () => {
|
||||
// Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
|
||||
it.skipIf(process.platform === 'win32')('rejects a config cwd directory without search permission at load', async () => {
|
||||
// statSync().isDirectory() is true for a mode-600 directory, but a
|
||||
// subprocess cwd needs SEARCH permission — spawn would fail EACCES.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
|
||||
@@ -472,13 +473,9 @@ describe('dsh-subagent-acp', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => {
|
||||
// A child that keeps its loop alive past stdin EOF (so the graceful window
|
||||
// times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier
|
||||
// — dispose returns there, never reaching the SIGKILL tier. The child touches
|
||||
// a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if
|
||||
// dispose had skipped the middle rung (EOF→SIGKILL) the handler would never
|
||||
// run and the marker would be absent — making this a GENUINE middle-tier guard.
|
||||
it('terminates a child that ignores EOF using the host platform semantics', async () => {
|
||||
// POSIX uses the catchable SIGTERM tier and records the marker. Windows has
|
||||
// no distinct graceful signal, so disposal skips directly to forced exit.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
|
||||
const ready = join(tmp, 'ready')
|
||||
const sigterm = join(tmp, 'sigterm')
|
||||
@@ -492,7 +489,7 @@ describe('dsh-subagent-acp', () => {
|
||||
MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
|
||||
MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm,
|
||||
},
|
||||
// Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM.
|
||||
// Tiny EOF grace so the ignored-EOF window elapses quickly.
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 2000,
|
||||
}
|
||||
@@ -503,9 +500,7 @@ describe('dsh-subagent-acp', () => {
|
||||
run.dispose(),
|
||||
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }),
|
||||
])).resolves.toBeUndefined()
|
||||
// The child caught SIGTERM and exited — proof the middle rung fired (not a
|
||||
// jump straight to the uncatchable SIGKILL).
|
||||
expect(existsSync(sigterm)).toBe(true)
|
||||
expect(existsSync(sigterm)).toBe(process.platform !== 'win32')
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ Spawn-failure capture: a promise that resolves (never rejects) with the child's
|
||||
|
||||
### `disposeChildProcess(child, graces)`
|
||||
|
||||
The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
|
||||
The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)):
|
||||
|
||||
1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact;
|
||||
2. `SIGTERM`, then wait `graces.disposeGraceMs`;
|
||||
3. `SIGKILL`, then await the now-certain exit — a child that ignores EOF and traps `SIGTERM` cannot wedge dispose forever.
|
||||
2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`;
|
||||
3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal.
|
||||
|
||||
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush.
|
||||
The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush.
|
||||
|
||||
The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child.
|
||||
|
||||
@@ -35,7 +35,7 @@ A per-run isolated config directory for an external CLI child (the target of `CL
|
||||
|
||||
## Testing
|
||||
|
||||
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end.
|
||||
`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -51,16 +51,6 @@ export function spawnFailure(child: ChildProcess): Promise<Error> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the child process exits (any code/signal); immediate if it is
|
||||
* already gone.
|
||||
* @param child - the child process to await.
|
||||
*/
|
||||
function waitForExit(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Race the child's exit against a timer. Neither outcome leaves anything
|
||||
* behind on the child: the exit listener is removed on timeout and the timer
|
||||
@@ -97,36 +87,85 @@ export interface DisposeLadderGraces {
|
||||
/**
|
||||
* Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce
|
||||
* ON ITS OWN — flush durable state, tear down its own nested subprocesses —
|
||||
* before the parent escalates to `SIGTERM`. A separate (usually WIDER)
|
||||
* before the parent escalates to platform termination. A separate (usually WIDER)
|
||||
* grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative
|
||||
* child's EOF-driven teardown may itself be waiting on a signal-trapping
|
||||
* grandchild plus a final flush, needing more than one signal-grace of
|
||||
* headroom.
|
||||
*/
|
||||
disposeEofGraceMs: number
|
||||
/** Tier-2 window (ms): between `SIGTERM` and the `SIGKILL` escalation. */
|
||||
/**
|
||||
* Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after
|
||||
* `SIGKILL`; Windows applies it after the direct forced termination.
|
||||
*/
|
||||
disposeGraceMs: number
|
||||
}
|
||||
|
||||
/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */
|
||||
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let accepted = false
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
const settle = (complete: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
complete()
|
||||
}
|
||||
const onExit = (): void => { settle(resolve) }
|
||||
const onError = (error: Error): void => { settle(() => { reject(error) }) }
|
||||
child.once('exit', onExit)
|
||||
child.once('error', onError)
|
||||
const timer = setTimeout(() => {
|
||||
const disposition = accepted ? 'accepted' : 'refused'
|
||||
settle(() => {
|
||||
reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
|
||||
})
|
||||
}, ms).unref()
|
||||
try {
|
||||
accepted = child.kill('SIGKILL')
|
||||
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
|
||||
} catch (error: unknown) {
|
||||
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear a child process down to quiescence, resolving only after exit: close stdin and allow
|
||||
* cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit.
|
||||
* cooperative flush, then use the host's graceful and forced termination semantics. POSIX
|
||||
* sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node
|
||||
* maps both signals to `TerminateProcess`.
|
||||
*
|
||||
* @param child - the child process to tear down.
|
||||
* @param graces - the two grace periods, from the consuming plugin's Config.
|
||||
* @param platform - the host platform, injectable for unit coverage.
|
||||
* @throws When forced termination errors or the child does not report exit within
|
||||
* `disposeGraceMs`.
|
||||
*/
|
||||
export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise<void> {
|
||||
export async function disposeChildProcess(
|
||||
child: ChildProcess,
|
||||
graces: DisposeLadderGraces,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
// Already gone: nothing to reap.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
// 1. Close stdin and allow cooperative teardown and durable-state flush.
|
||||
child.stdin?.end()
|
||||
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
|
||||
// 2. SIGTERM, escalating if the child still does not exit within the grace.
|
||||
child.kill('SIGTERM')
|
||||
if (await exitsWithin(child, graces.disposeGraceMs)) return
|
||||
// 3. Force-kill and await the (now-certain) exit.
|
||||
child.kill('SIGKILL')
|
||||
await waitForExit(child)
|
||||
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
|
||||
if (platform !== 'win32') {
|
||||
child.kill('SIGTERM')
|
||||
if (await exitsWithin(child, graces.disposeGraceMs)) return
|
||||
}
|
||||
// 3. Force-kill and await a bounded exit edge.
|
||||
await forceTerminateWithin(child, graces.disposeGraceMs)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('disposeChildProcess', () => {
|
||||
|
||||
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
|
||||
expect(fake.stdinEnded).toBe(true)
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
expect(fake.signalCode).toBe('SIGTERM')
|
||||
@@ -200,7 +200,7 @@ describe('disposeChildProcess', () => {
|
||||
|
||||
it('recognizes a child that exits synchronously on SIGTERM', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
expect(fake.signalCode).toBe('SIGTERM')
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
@@ -208,7 +208,7 @@ describe('disposeChildProcess', () => {
|
||||
|
||||
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
|
||||
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
// Quiescence, not a request: at resolution the child has ACTUALLY exited
|
||||
// (the exit event landed, despite the scripted post-SIGKILL delay).
|
||||
@@ -217,16 +217,103 @@ describe('disposeChildProcess', () => {
|
||||
|
||||
it('recognizes a child already gone when the final exit wait begins', async () => {
|
||||
const fake = new FakeChild({ synchronousExit: true })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
expect(fake.signalCode).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
|
||||
const fake = new FakeChild()
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
queueMicrotask(() => {
|
||||
if (marker === 'exitCode') fake.exitCode = 0
|
||||
else fake.signalCode = 'SIGTERM'
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('walks the ladder for a child spawned without a stdin pipe', async () => {
|
||||
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
|
||||
await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
|
||||
expect(fake.kills).toEqual(['SIGKILL'])
|
||||
expect(fake.signalCode).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('propagates a forced-termination error without waiting for the grace', async () => {
|
||||
const fake = new FakeChild()
|
||||
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
fake.emit('error', failure)
|
||||
return false
|
||||
})
|
||||
|
||||
await expect(disposeChildProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
|
||||
'win32',
|
||||
)).rejects.toBe(failure)
|
||||
expect(fake.kills).toEqual(['SIGKILL'])
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
|
||||
const fake = new FakeChild()
|
||||
const failure = new Error('invalid signal state')
|
||||
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
|
||||
|
||||
await expect(disposeChildProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
|
||||
'win32',
|
||||
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds a refused forced termination that produces no error or exit', async () => {
|
||||
const fake = new FakeChild()
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
return false
|
||||
})
|
||||
|
||||
await expect(disposeChildProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
|
||||
'win32',
|
||||
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused')
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds an accepted forced termination that never reports exit', async () => {
|
||||
const fake = new FakeChild()
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
return true
|
||||
})
|
||||
|
||||
await expect(disposeChildProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
|
||||
'win32',
|
||||
)).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted')
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createIsolatedConfigDir', () => {
|
||||
@@ -236,8 +323,9 @@ describe('createIsolatedConfigDir', () => {
|
||||
expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true)
|
||||
const st = await stat(dir.path)
|
||||
expect(st.isDirectory()).toBe(true)
|
||||
// Private (0700) per the defensive-patterns temp-dir rule.
|
||||
expect(st.mode & 0o777).toBe(0o700)
|
||||
// Windows reports synthetic POSIX mode bits; privacy comes from the
|
||||
// inherited directory ACL rather than chmod-compatible mode bits.
|
||||
if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700)
|
||||
} finally {
|
||||
await dir.remove()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie
|
||||
|
||||
Four layers, importable separately:
|
||||
|
||||
- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
|
||||
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the expected-output and purity checks, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a temp cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
|
||||
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo. Startup failures preserve captured agent stderr in the rejected diagnostic.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Replay may partition subprocess-backed scenarios with `scenarioShard`; every lane still runs fixture guards against the complete table, while record and refresh reject sharding because they write fixtures. Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
|
||||
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
|
||||
@@ -38,6 +38,8 @@ defineAcpSnapshotSuite({
|
||||
|
||||
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences.
|
||||
|
||||
Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere.
|
||||
|
||||
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
|
||||
|
||||
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript).
|
||||
@@ -53,4 +55,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set `persistenceCompression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path.
|
||||
- **The subprocess boots the unbuilt tsx/Loader path only** — the built-bin artifact is guarded by the separate `built-bin` e2e smokes, never by this tier.
|
||||
- **Built mode requires current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path.
|
||||
|
||||
@@ -153,11 +153,21 @@ export interface RunOptions {
|
||||
configPath?: string
|
||||
}
|
||||
|
||||
/** Derive one stable, fixed-length spill root owned by this scenario. */
|
||||
function scenarioSpillRoot(fixtureFile: string): string {
|
||||
/**
|
||||
* Derive one stable, fixed-length spill root owned by this scenario.
|
||||
* Windows uses a two-character-shorter root because drive resolution adds its drive prefix.
|
||||
* @param fixtureFile - The scenario fixture whose parent directory provides the stable identity.
|
||||
* @param platform - the host platform, injectable for unit coverage.
|
||||
* @returns the root-relative snapshot spill directory.
|
||||
*/
|
||||
export function snapshotSpillRoot(
|
||||
fixtureFile: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
const scenario = basename(dirname(fixtureFile))
|
||||
const key = createHash('sha256').update(scenario).digest('hex').slice(0, 9)
|
||||
return `/tmp/dsh-acp-snap-${key}`
|
||||
const root = platform === 'win32' ? '/t' : '/tmp'
|
||||
return `${root}/dsh-acp-snap-${key}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +186,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
// before stdout normalization, so tmpdir() length differences churn expected outputs.
|
||||
// Scenario ownership also matters: replay runs concurrently, and one teardown
|
||||
// must never delete another scenario's in-flight full-output recovery file.
|
||||
const spillRoot = scenarioSpillRoot(opts.fixtureFile)
|
||||
const spillRoot = snapshotSpillRoot(opts.fixtureFile)
|
||||
// Everything past the temp-dir creation is followed by failure-safe cleanup,
|
||||
// so a failure in workspace seeding, spawn, or any step never leaks resources.
|
||||
let launched: LaunchedAcpTestAgent | undefined
|
||||
|
||||
@@ -37,7 +37,9 @@ export {
|
||||
scrubRequestHeaders,
|
||||
scrubSystemPrompts,
|
||||
scrubToolSchemas,
|
||||
type CwdPathMode,
|
||||
type NormalizeContext,
|
||||
type NormalizeOptions,
|
||||
} from './normalize.ts'
|
||||
export {
|
||||
defineAcpSnapshotSuite,
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const EXIT_MARKER_GRACE_MS = 250
|
||||
|
||||
/** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */
|
||||
export interface AgentUnderTest {
|
||||
/** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
|
||||
@@ -231,6 +233,15 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
||||
return
|
||||
}
|
||||
|
||||
const propagateFailureAfterDrain = async (): Promise<never> => {
|
||||
await drained
|
||||
closeUpdateStream()
|
||||
throw failure
|
||||
}
|
||||
// Windows implements the supported signal names as forced termination. The exit markers
|
||||
// may therefore arrive after the error wins the race above but before fallback begins.
|
||||
if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain()
|
||||
|
||||
// An `error` after spawn is not an exit edge: in particular, a failed
|
||||
// signal can leave the subprocess live. Force termination, await the
|
||||
// already-observed exit edge, and only then propagate the child error so
|
||||
@@ -240,6 +251,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
||||
child.once('error', observeFallbackError)
|
||||
if (!child.kill('SIGKILL')) {
|
||||
child.off('error', observeFallbackError)
|
||||
// A successful earlier signal may win between the live check and this fallback call.
|
||||
// In that case `kill()` correctly reports no process to signal; the original child error
|
||||
// remains the shutdown result once inherited stdio and callbacks have drained.
|
||||
if (!isRunning(child) || await exitMarkerWithinGrace(exited)) return propagateFailureAfterDrain()
|
||||
closeUpdateStream()
|
||||
throw new AggregateError(
|
||||
[failure, new Error('Fallback SIGKILL was not accepted by the child process')],
|
||||
@@ -258,9 +273,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
|
||||
'ACP test agent failed and fallback termination was refused',
|
||||
)
|
||||
}
|
||||
await drained
|
||||
closeUpdateStream()
|
||||
throw failure
|
||||
return propagateFailureAfterDrain()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -270,6 +283,17 @@ function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
|
||||
}
|
||||
|
||||
/** Give an accepted Windows termination request a bounded window to publish its exit marker. */
|
||||
function exitMarkerWithinGrace(exited: Promise<void>): Promise<boolean> {
|
||||
return Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise<false>((resolve) => {
|
||||
const timer = setTimeout(() => { resolve(false) }, EXIT_MARKER_GRACE_MS)
|
||||
timer.unref()
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/** Whether the child still lacks either OS termination marker. */
|
||||
function isRunning(child: ChildProcessWithoutNullStreams): boolean {
|
||||
return child.exitCode === null && child.signalCode === null
|
||||
|
||||
@@ -13,19 +13,33 @@ const TOOLS = '{{tools}}'
|
||||
const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||
const UPDATED_AT = '{{updatedAt}}'
|
||||
|
||||
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
|
||||
const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g
|
||||
const PATH_TAG_RE = /(<path>)([^<]*)(<\/path>)/g
|
||||
const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g
|
||||
|
||||
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
|
||||
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
const LOCAL_SPILL_PATH_RE = new RegExp(
|
||||
String.raw`\{\{cwd\}\}/\.spill/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
|
||||
String.raw`\{\{cwd\}\}[\\/]\.spill[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
|
||||
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
|
||||
'g',
|
||||
)
|
||||
const SNAPSHOT_SPILL_PATH_RE = new RegExp(
|
||||
String.raw`/tmp/(?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)/session-[0-9a-f]{12}/[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
|
||||
String.raw`(?:[A-Za-z]:)?[\\/](?:tmp|t)[\\/](?:dsh-acp-snap-[0-9a-f]{9}|dsh-acp-snapshot-spill)[\\/]session-[0-9a-f]{12}[\\/][0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
|
||||
+ String.raw`(?=\. Use read with offset/limit|[\s)]|$)`,
|
||||
'g',
|
||||
)
|
||||
|
||||
/** Convert separators only inside generated path-bearing text markers. */
|
||||
function canonicalizeEmbeddedPaths(value: string): string {
|
||||
return value
|
||||
.replace(PATH_TAG_RE, (_match, open: string, path: string, close: string) =>
|
||||
`${open}${path.replaceAll('\\', '/')}${close}`)
|
||||
.replace(ADDITIONAL_INSTRUCTIONS_PATH_RE, (_match, prefix: string, path: string) =>
|
||||
`${prefix}${path.replaceAll('\\', '/')}`)
|
||||
}
|
||||
|
||||
/** Inputs the normalizers need to recognize a run's volatile values. */
|
||||
export interface NormalizeContext {
|
||||
/** The session id(s) the run issued — replaced with `{{sessionId}}`. */
|
||||
@@ -34,13 +48,28 @@ export interface NormalizeContext {
|
||||
cwd: string
|
||||
}
|
||||
|
||||
/** How cwd-rooted path separators are represented after the cwd is tokenized. */
|
||||
export type CwdPathMode = 'canonical' | 'native'
|
||||
|
||||
/** Optional controls shared by stdout and session-log normalization. */
|
||||
export interface NormalizeOptions {
|
||||
/** Use `/` for shared goldens, or preserve captured separators for a platform-specific golden. */
|
||||
cwdPathMode?: CwdPathMode
|
||||
}
|
||||
|
||||
/** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
|
||||
function scrubString(value: string, ctx: NormalizeContext): string {
|
||||
function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathMode): string {
|
||||
let out = value
|
||||
// cwd first (longest, most specific), then explicit session ids, then any
|
||||
// residual UUID (covers ids that appear in places we didn't enumerate).
|
||||
out = out.split(ctx.cwd).join(CWD)
|
||||
out = out.split(`/private${CWD}`).join(CWD)
|
||||
if (cwdPathMode === 'canonical') {
|
||||
// Restrict separator conversion to paths rooted at the cwd token. A global
|
||||
// backslash rewrite would corrupt regexes, commands, and model-authored text.
|
||||
out = out.replace(CWD_ROOTED_PATH_RE, path => path.replaceAll('\\', '/'))
|
||||
out = canonicalizeEmbeddedPaths(out)
|
||||
}
|
||||
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
|
||||
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
|
||||
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
|
||||
@@ -49,12 +78,15 @@ function scrubString(value: string, ctx: NormalizeContext): string {
|
||||
}
|
||||
|
||||
/** Recursively scrub a parsed JSON value (strings replaced; structure kept). */
|
||||
function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
|
||||
if (typeof value === 'string') return scrubString(value, ctx)
|
||||
if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx))
|
||||
function scrubValue(value: unknown, ctx: NormalizeContext, cwdPathMode: CwdPathMode, key?: string): unknown {
|
||||
if (typeof value === 'string') {
|
||||
const scrubbed = scrubString(value, ctx, cwdPathMode)
|
||||
return cwdPathMode === 'canonical' && key === 'path' ? scrubbed.replaceAll('\\', '/') : scrubbed
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx, cwdPathMode))
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx)
|
||||
for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx, cwdPathMode, k)
|
||||
return out
|
||||
}
|
||||
return value
|
||||
@@ -68,9 +100,15 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
|
||||
*
|
||||
* @param rawStdout The captured stdout bytes, decoded utf8.
|
||||
* @param ctx The run's volatile values to scrub.
|
||||
* @param options Separator output controls; shared canonical paths are the default.
|
||||
* @returns The normalized NDJSON transcript, one frame per line.
|
||||
*/
|
||||
export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string {
|
||||
export function normalizeStdout(
|
||||
rawStdout: string,
|
||||
ctx: NormalizeContext,
|
||||
options: NormalizeOptions = {},
|
||||
): string {
|
||||
const cwdPathMode = options.cwdPathMode ?? 'canonical'
|
||||
const lines = rawStdout.split('\n').filter(line => line.trim().length > 0)
|
||||
// Map each distinct JSON-RPC id (request/response correlate by id) to a stable
|
||||
// sequence number, in first-seen order, so id churn doesn't perturb the expected output.
|
||||
@@ -88,7 +126,7 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin
|
||||
}
|
||||
const update = (frame.params as { update?: Record<string, unknown> } | undefined)?.update
|
||||
if (update?.sessionUpdate === 'session_info_update') update.updatedAt = UPDATED_AT
|
||||
return scrubValue(frame, ctx) as Record<string, unknown>
|
||||
return scrubValue(frame, ctx, cwdPathMode) as Record<string, unknown>
|
||||
})
|
||||
return frames.map(f => JSON.stringify(f)).join('\n') + '\n'
|
||||
}
|
||||
@@ -102,9 +140,15 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin
|
||||
*
|
||||
* @param rawLog The raw session `.jsonl` content.
|
||||
* @param ctx The run's volatile values to scrub.
|
||||
* @param options Separator output controls; shared canonical paths are the default.
|
||||
* @returns The normalized JSONL log, one record per line.
|
||||
*/
|
||||
export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): string {
|
||||
export function normalizeSessionLog(
|
||||
rawLog: string,
|
||||
ctx: NormalizeContext,
|
||||
options: NormalizeOptions = {},
|
||||
): string {
|
||||
const cwdPathMode = options.cwdPathMode ?? 'canonical'
|
||||
const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
|
||||
const records = lines.map((line) => {
|
||||
const record = JSON.parse(line) as Record<string, unknown>
|
||||
@@ -122,7 +166,7 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
|
||||
if ('durationMs' in data) data.durationMs = 0
|
||||
}
|
||||
}
|
||||
return scrubValue(record, ctx) as Record<string, unknown>
|
||||
return scrubValue(record, ctx, cwdPathMode) as Record<string, unknown>
|
||||
})
|
||||
return records.map(r => JSON.stringify(r)).join('\n') + '\n'
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts'
|
||||
import {
|
||||
type CwdPathMode,
|
||||
type NormalizeContext,
|
||||
normalizeSessionLog,
|
||||
normalizeStdout,
|
||||
@@ -36,6 +37,9 @@ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md'
|
||||
/** The structured tool-schema snapshot beside each header-pinning fixture. */
|
||||
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json'
|
||||
|
||||
/** The optional full Windows-native stdout transcript. */
|
||||
const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
|
||||
|
||||
/** Stable session-log token standing in for the sidecar's initial schemas. */
|
||||
const TOOLS_TOKEN = '{{tools}}'
|
||||
|
||||
@@ -101,6 +105,61 @@ export interface Scenario {
|
||||
* {@link headerClass}.
|
||||
*/
|
||||
configPath?: string
|
||||
/**
|
||||
* Whether Windows additionally compares stdout with native separators against
|
||||
* `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still
|
||||
* compared on every platform, and the fixture guard requires this sidecar
|
||||
* exactly when the option is set.
|
||||
*/
|
||||
pinsNativeWindowsStdout?: boolean
|
||||
/**
|
||||
* Whether the driven behavior needs POSIX process semantics the harness
|
||||
* cannot exercise on Windows (e.g. cancelling a live bash tool call kills a
|
||||
* detached process group). The scenario's run test is skipped on Windows;
|
||||
* its fixtures stay guarded on every platform.
|
||||
*/
|
||||
posixOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scenario's run test is skipped for this mode and host: record mode
|
||||
* skips authored (non-`recorded`) scenarios, and {@link Scenario.posixOnly}
|
||||
* scenarios skip on Windows.
|
||||
*
|
||||
* @param scenario The scenario whose run test is being registered.
|
||||
* @param recording Whether the suite runs in record mode.
|
||||
* @param platform The running Node platform, injectable for unit coverage.
|
||||
* @returns True when the scenario's run test must not execute.
|
||||
*/
|
||||
export function scenarioSkipped(
|
||||
scenario: Scenario,
|
||||
recording: boolean,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (recording && !scenario.recorded) return true
|
||||
return scenario.posixOnly === true && platform === 'win32'
|
||||
}
|
||||
|
||||
/** One stdout expected output selected for a platform run. */
|
||||
interface StdoutExpectedVariant {
|
||||
file: string
|
||||
cwdPathMode: CwdPathMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the shared stdout expected output plus any platform-native assertion declared by a scenario.
|
||||
*
|
||||
* @param scenario The scenario whose stdout contract is being selected.
|
||||
* @param platform The running Node platform, injectable for unit coverage.
|
||||
* @returns The ordered expected-output variants: shared canonical first, then optional Windows native.
|
||||
*/
|
||||
export function stdoutExpectedVariants(
|
||||
scenario: Scenario,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): StdoutExpectedVariant[] {
|
||||
const canonical: StdoutExpectedVariant = { file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' }
|
||||
if (platform !== 'win32' || scenario.pinsNativeWindowsStdout !== true) return [canonical]
|
||||
return [canonical, { file: WINDOWS_STDOUT_SNAPSHOT, cwdPathMode: 'native' }]
|
||||
}
|
||||
|
||||
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
|
||||
@@ -489,8 +548,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
scenarioSuite('snapshot scenarios', () => {
|
||||
for (const scenario of selectedScenarios) {
|
||||
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones
|
||||
// (sidecar-driven errors/cancel) are never re-recorded.
|
||||
it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
|
||||
// (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on
|
||||
// Windows, where their process semantics cannot be driven.
|
||||
it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => {
|
||||
const dir = join(snapshotsDir, scenario.name)
|
||||
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
@@ -594,11 +654,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
const stdout = normalizeStdout(result.rawStdout, ctx)
|
||||
if (REFRESHING) {
|
||||
await writeFile(join(dir, 'stdout.expected.jsonl'), stdout)
|
||||
for (const expected of stdoutExpectedVariants(scenario)) {
|
||||
const stdout = normalizeStdout(result.rawStdout, ctx, { cwdPathMode: expected.cwdPathMode })
|
||||
if (REFRESHING) {
|
||||
await writeFile(join(dir, expected.file), stdout)
|
||||
}
|
||||
await expect(stdout, `${expected.file} mismatch`).toMatchFileSnapshot(join(dir, expected.file))
|
||||
}
|
||||
await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.expected.jsonl'))
|
||||
|
||||
// A model turn always produces a log worth comparing; a hook scenario can
|
||||
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
|
||||
@@ -685,10 +747,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
|
||||
it('every registered scenario has its required fixture files', async () => {
|
||||
// Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars.
|
||||
for (const { name, overridden, pinsHeader } of scenarios) {
|
||||
for (const { name, overridden, pinsHeader, pinsNativeWindowsStdout } of scenarios) {
|
||||
const dir = join(snapshotsDir, name)
|
||||
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
|
||||
expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true)
|
||||
expect(
|
||||
existsSync(join(dir, WINDOWS_STDOUT_SNAPSHOT)),
|
||||
`${name}/${WINDOWS_STDOUT_SNAPSHOT} presence must match \`pinsNativeWindowsStdout\``,
|
||||
).toBe(pinsNativeWindowsStdout === true)
|
||||
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
|
||||
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``)
|
||||
.toBe(overridden === true)
|
||||
|
||||
@@ -300,7 +300,10 @@ function flushLogsAndExit(): void {
|
||||
`setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`,
|
||||
`setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`,
|
||||
].join(';')
|
||||
spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref()
|
||||
spawn(process.execPath, ['-e', code], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
}).unref()
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts'
|
||||
import { runScenario, snapshotSpillRoot, type AgentUnderTest, type InputStep } from '../src/harness.ts'
|
||||
import { launchAcpTestAgent } from '../src/launcher.ts'
|
||||
|
||||
const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined }))
|
||||
@@ -60,6 +60,15 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s
|
||||
|
||||
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
|
||||
|
||||
it('keeps scenario-owned snapshot spill root length stable across platforms', () => {
|
||||
const fixtureFile = '/fixtures/scenario/session.jsonl'
|
||||
const posix = snapshotSpillRoot(fixtureFile, 'linux')
|
||||
const windows = snapshotSpillRoot(fixtureFile, 'win32')
|
||||
expect(posix).toMatch(/^\/tmp\/dsh-acp-snap-[0-9a-f]{9}$/)
|
||||
expect(windows).toMatch(/^\/t\/dsh-acp-snap-[0-9a-f]{9}$/)
|
||||
expect(windows.length + 2).toBe(posix.length)
|
||||
})
|
||||
|
||||
function environmentEcho(rawStdout: string): Record<string, unknown> {
|
||||
const frames = rawStdout.trim().split('\n')
|
||||
.map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
|
||||
@@ -143,6 +152,9 @@ describe('runScenario', () => {
|
||||
update.sessionUpdate === 'agent_message_chunk'
|
||||
&& update.content.type === 'text'
|
||||
&& update.content.text === 'late inherited stdout')
|
||||
// Arm rejection handling before close may exhaust the stream; the later assertion still
|
||||
// observes the original promise and turns a missing inherited frame into the test failure.
|
||||
void lateUpdate.catch(() => undefined)
|
||||
|
||||
await launched.close()
|
||||
|
||||
@@ -180,6 +192,97 @@ describe('runScenario', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the child error when the requested signal sets an exit marker', async () => {
|
||||
const { dir } = await scenario({})
|
||||
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||
await launched.spawned
|
||||
|
||||
const childFailure = Object.assign(new Error('signal failed as the child exited'), { code: 'EPERM' })
|
||||
const originalKill = launched.child.kill.bind(launched.child)
|
||||
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
|
||||
expect(signal).toBe('SIGTERM')
|
||||
originalKill('SIGKILL')
|
||||
Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGTERM' })
|
||||
return true
|
||||
})
|
||||
try {
|
||||
launched.child.emit('error', childFailure)
|
||||
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
|
||||
expect(kill).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the child error when the requested signal publishes its exit marker later', async () => {
|
||||
const { dir } = await scenario({})
|
||||
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||
await launched.spawned
|
||||
|
||||
const childFailure = Object.assign(new Error('signal failed before the delayed exit marker'), { code: 'EPERM' })
|
||||
const originalKill = launched.child.kill.bind(launched.child)
|
||||
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
|
||||
expect(signal).toBe('SIGTERM')
|
||||
setTimeout(() => { originalKill('SIGKILL') }, 10)
|
||||
return true
|
||||
})
|
||||
try {
|
||||
launched.child.emit('error', childFailure)
|
||||
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
|
||||
expect(kill).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the child error when fallback refusal races with an exit marker', async () => {
|
||||
const { dir } = await scenario({})
|
||||
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||
await launched.spawned
|
||||
|
||||
const childFailure = Object.assign(new Error('signal failed while the child exited'), { code: 'EPERM' })
|
||||
const originalKill = launched.child.kill.bind(launched.child)
|
||||
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
|
||||
if (signal === 'SIGTERM') return true
|
||||
originalKill('SIGKILL')
|
||||
Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGKILL' })
|
||||
return false
|
||||
})
|
||||
try {
|
||||
launched.child.emit('error', childFailure)
|
||||
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
|
||||
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
|
||||
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the child error after accepted fallback termination drains', async () => {
|
||||
const { dir } = await scenario({})
|
||||
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||
await launched.spawned
|
||||
|
||||
const childFailure = Object.assign(new Error('requested signal failed before fallback'), { code: 'EPERM' })
|
||||
const originalKill = launched.child.kill.bind(launched.child)
|
||||
const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
|
||||
if (signal === 'SIGTERM') return true
|
||||
return originalKill('SIGKILL')
|
||||
})
|
||||
try {
|
||||
launched.child.emit('error', childFailure)
|
||||
await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
|
||||
expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
|
||||
expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
|
||||
} finally {
|
||||
kill.mockRestore()
|
||||
if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects promptly when fallback termination emits an error', async () => {
|
||||
const { dir } = await scenario({})
|
||||
const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
|
||||
@@ -294,7 +397,11 @@ describe('runScenario', () => {
|
||||
expect(result.sessionLogs[0]?.createdAt).toBe(42)
|
||||
expect(result.sessionLogs[0]?.content).toContain('turn/start')
|
||||
// The harvested log embeds the run's REAL temp cwd (template-substituted).
|
||||
expect(result.sessionLogs[0]?.content).toContain(result.cwd)
|
||||
// The cwd is JSON-encoded in the log line, so compare the parsed field
|
||||
// rather than substring-matching a raw path (which breaks when the path
|
||||
// separator is escaped inside JSON text on Windows).
|
||||
const sessionLine = result.sessionLogs[0]?.content.split('\n').find(l => l.includes('"type":"session"')) ?? '{}'
|
||||
expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd)
|
||||
})
|
||||
|
||||
it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
|
||||
@@ -315,7 +422,17 @@ describe('runScenario', () => {
|
||||
expect(result.stderr).toContain('fake bin booted')
|
||||
expect(result.rawStdout).toContain('replay.override.json')
|
||||
// Child paths ride one env var, joined with the platform delimiter.
|
||||
expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1))
|
||||
// Parse the fake bin's env-probe chunk rather than substring-matching a
|
||||
// JSON-encoded path (the escaping breaks raw-substring compares on Windows).
|
||||
const envChunk = result.rawStdout.split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(l => l.length > 0)
|
||||
.map(l => JSON.parse(l) as { params?: { update?: { content?: { text?: string } } } })
|
||||
.find(f => f.params?.update?.content?.text?.startsWith('env:'))
|
||||
const env = JSON.parse((envChunk?.params?.update?.content?.text ?? 'env:{}').slice('env:'.length)) as {
|
||||
childFiles: string | null
|
||||
}
|
||||
expect(env.childFiles).toBe(childFiles.join(delimiter))
|
||||
})
|
||||
|
||||
it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => {
|
||||
@@ -328,7 +445,10 @@ describe('runScenario', () => {
|
||||
expect(roots.every(root => typeof root === 'string')).toBe(true)
|
||||
expect(new Set(roots).size).toBe(2)
|
||||
expect((roots[0] as string).length).toBe((roots[1] as string).length)
|
||||
expect((roots[0] as string).length).toBe('/tmp/dsh-acp-snapshot-spill'.length)
|
||||
expect(roots).toEqual([
|
||||
snapshotSpillRoot(first.fixtureFile),
|
||||
snapshotSpillRoot(second.fixtureFile),
|
||||
])
|
||||
})
|
||||
|
||||
it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
|
||||
|
||||
@@ -44,6 +44,56 @@ describe('normalizeStdout', () => {
|
||||
expect(out).not.toContain(ctx.sessionIds[0] as string)
|
||||
})
|
||||
|
||||
it('canonicalizes only cwd-rooted path separators', () => {
|
||||
const windowsCtx: NormalizeContext = {
|
||||
sessionIds: [],
|
||||
cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`,
|
||||
}
|
||||
const raw = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
path: `${windowsCtx.cwd}\\nested\\proof.txt`,
|
||||
regex: String.raw`\d+\w+`,
|
||||
command: String.raw`printf "\\n"`,
|
||||
},
|
||||
})
|
||||
const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as {
|
||||
params: { path: string; regex: string; command: string }
|
||||
}
|
||||
expect(frame.params).toEqual({
|
||||
path: '{{cwd}}/nested/proof.txt',
|
||||
regex: String.raw`\d+\w+`,
|
||||
command: String.raw`printf "\\n"`,
|
||||
})
|
||||
})
|
||||
|
||||
it('canonicalizes generated relative path fields and text markers without rewriting other text', () => {
|
||||
const raw = JSON.stringify({
|
||||
path: String.raw`nested\AGENTS.md`,
|
||||
content: String.raw`<path>.\nested\task.txt</path>
|
||||
Additional instructions from: nested\AGENTS.md`,
|
||||
regex: String.raw`\d+\w+`,
|
||||
})
|
||||
const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as {
|
||||
path: string
|
||||
content: string
|
||||
regex: string
|
||||
}
|
||||
expect(frame).toEqual({
|
||||
path: 'nested/AGENTS.md',
|
||||
content: '<path>./nested/task.txt</path>\nAdditional instructions from: nested/AGENTS.md',
|
||||
regex: String.raw`\d+\w+`,
|
||||
})
|
||||
})
|
||||
|
||||
it('can preserve native cwd-rooted separators for a platform golden', () => {
|
||||
const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
|
||||
const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` })
|
||||
const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string }
|
||||
expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`)
|
||||
})
|
||||
|
||||
it('scrubs a stray UUID not in the known list', () => {
|
||||
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
|
||||
expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
|
||||
@@ -172,6 +222,33 @@ describe('normalizeSessionLog', () => {
|
||||
expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
|
||||
})
|
||||
|
||||
it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snap-012345678\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
|
||||
}],
|
||||
},
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{spillLocator:bash.txt}}')
|
||||
expect(out).not.toContain('C:\\t\\dsh-acp-snap-012345678')
|
||||
})
|
||||
|
||||
it('shares cwd-rooted path handling with stdout normalization', () => {
|
||||
const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` },
|
||||
})
|
||||
expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx))
|
||||
.toContain('{{cwd}}/nested/proof.txt')
|
||||
expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' }))
|
||||
.toContain(String.raw`{{cwd}}\\nested\\proof.txt`)
|
||||
})
|
||||
|
||||
it('scrubs the session id in the header', () => {
|
||||
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
|
||||
expect(out).toContain('{{sessionId}}')
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
normalizedToolSchemas,
|
||||
parseToolSchemasSnapshot,
|
||||
refreshFixtureReplacements,
|
||||
scenarioSkipped,
|
||||
sessionFixtureNames,
|
||||
restorePinnedToolSchemas,
|
||||
stabilizeRefreshLog,
|
||||
stdoutExpectedVariants,
|
||||
unknownToolCallIds,
|
||||
} from '../src/suite.ts'
|
||||
|
||||
@@ -252,6 +254,48 @@ describe('sessionFixtureNames', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdoutExpectedVariants', () => {
|
||||
const scenario: Scenario = {
|
||||
name: 'windows-native',
|
||||
hasModelTurn: true,
|
||||
recorded: true,
|
||||
pinsNativeWindowsStdout: true,
|
||||
}
|
||||
|
||||
it('adds the native sidecar after the shared golden on Windows', () => {
|
||||
expect(stdoutExpectedVariants(scenario, 'win32')).toEqual([
|
||||
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
|
||||
{ file: 'stdout.expected.windows.jsonl', cwdPathMode: 'native' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps only the shared golden on other platforms or without the declaration', () => {
|
||||
expect(stdoutExpectedVariants(scenario, 'linux')).toEqual([
|
||||
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
|
||||
])
|
||||
expect(stdoutExpectedVariants({ ...scenario, pinsNativeWindowsStdout: false }, 'win32')).toEqual([
|
||||
{ file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scenarioSkipped', () => {
|
||||
const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false }
|
||||
const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true }
|
||||
|
||||
it('skips authored scenarios only while recording', () => {
|
||||
expect(scenarioSkipped(authored, true, 'linux')).toBe(true)
|
||||
expect(scenarioSkipped(authored, false, 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips posixOnly scenarios on Windows and nowhere else', () => {
|
||||
expect(scenarioSkipped(posix, false, 'win32')).toBe(true)
|
||||
expect(scenarioSkipped(posix, false, 'linux')).toBe(false)
|
||||
expect(scenarioSkipped(posix, false, 'darwin')).toBe(false)
|
||||
expect(scenarioSkipped(authored, false, 'win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fixtureContext', () => {
|
||||
it('reads the fixture header id and cwd', () => {
|
||||
const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n')
|
||||
|
||||
@@ -37,8 +37,8 @@ describe('runLoaderSmoke', () => {
|
||||
marker: 'present',
|
||||
input: 'one\ntwo\n',
|
||||
})
|
||||
expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`)
|
||||
expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`)
|
||||
expect(canonicalTempPath(output.dshHome)).toBe(canonicalTempPath(join(output.cwd, '.dsh')))
|
||||
expect(canonicalTempPath(output.agentsHome)).toBe(canonicalTempPath(join(output.cwd, '.agents')))
|
||||
expect(result.stderr).toContain('fixture stderr')
|
||||
expect(existsSync(output.cwd)).toBe(false)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
@@ -63,11 +63,11 @@ A log-only `session/title` event maps to ACP `session_info_update` with `title`
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
|
||||
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. File-card titles are relative to the session cwd and use the host separator, while location and diff paths remain raw so the editor opens the real file. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
|
||||
|
||||
## Terminal card (capability-gated)
|
||||
|
||||
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session and preserves the host filesystem separator, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
|
||||
## Settle-exactly-once
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join as pathJoin, resolve as pathResolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -50,6 +51,16 @@ function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent
|
||||
return { type, seq: 0, time: 0, data } as SessionEvent
|
||||
}
|
||||
|
||||
/** ACP path fields are filesystem paths; expectations use the host separator. */
|
||||
function nativePath(...segments: string[]): string {
|
||||
return pathJoin(...segments)
|
||||
}
|
||||
|
||||
/** Resolve root-relative fixtures the same way the bridge does on this host. */
|
||||
function nativeAbsolute(...segments: string[]): string {
|
||||
return pathResolve(...segments)
|
||||
}
|
||||
|
||||
describe('streamSessionEventUpdate', () => {
|
||||
it('maps a title event to session_info_update with the event timestamp', () => {
|
||||
expect(updatesFor({
|
||||
@@ -576,10 +587,10 @@ describe('terminal-card mapping (capability-gated)', () => {
|
||||
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
|
||||
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
|
||||
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/work/proj'), callEvent)
|
||||
// Relative workdir resolved against the session cwd — the card header matches
|
||||
// where execution actually ran (tool-bash resolves the same way).
|
||||
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
|
||||
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe(nativeAbsolute('/work/proj', 'sub', 'dir'))
|
||||
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
|
||||
const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
|
||||
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
|
||||
@@ -792,10 +803,12 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
|
||||
// paths remain absolute so the editor can open the real file.
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
|
||||
const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
|
||||
const workspace = nativeAbsolute('/work/proj')
|
||||
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
|
||||
const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' })
|
||||
const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
|
||||
const out: SessionNotification['update'][] = []
|
||||
const rendering = { enabled: false, cwd: '/work/proj' }
|
||||
const rendering = { enabled: false, cwd: workspace }
|
||||
for (const event of [
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
|
||||
@@ -804,8 +817,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'e1',
|
||||
status: 'completed',
|
||||
title: 'Edit src/b.ts',
|
||||
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
|
||||
title: `Edit ${nativePath('src', 'b.ts')}`,
|
||||
content: [{ type: 'diff', path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -856,21 +869,25 @@ describe('relative-path display titles (bridge relativizes the title against the
|
||||
|
||||
it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 })
|
||||
const workspace = nativeAbsolute('/work/proj')
|
||||
const file = nativeAbsolute('/work/proj', 'src', 'a.ts')
|
||||
const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 })
|
||||
expect(update).toMatchObject({
|
||||
title: 'Read src/a.ts (from line 5)',
|
||||
locations: [{ path: '/work/proj/src/a.ts', line: 5 }],
|
||||
title: `Read ${nativePath('src', 'a.ts')} (from line 5)`,
|
||||
locations: [{ path: file, line: 5 }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' })
|
||||
const workspace = nativeAbsolute('/work/proj')
|
||||
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
|
||||
const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' })
|
||||
expect(update).toMatchObject({
|
||||
title: 'Edit src/b.ts',
|
||||
locations: [{ path: '/work/proj/src/b.ts' }],
|
||||
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }],
|
||||
title: `Edit ${nativePath('src', 'b.ts')}`,
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: 'diff', path: file, oldText: 'x', newText: 'y' }],
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -887,8 +904,8 @@ describe('relative-path display titles (bridge relativizes the title against the
|
||||
// with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it,
|
||||
// matching targets under `cwd + sep` in the reference adapter.
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
|
||||
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')
|
||||
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') })
|
||||
expect((update as { title: string }).title).toBe(`Read ${nativePath('..cache', 'x.ts')}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -901,8 +918,8 @@ describe('relative-path display titles (bridge relativizes the title against the
|
||||
|
||||
it('a relative path is passed through unchanged (already display-friendly)', async () => {
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' })
|
||||
expect((update as { title: string }).title).toBe('Read src/a.ts')
|
||||
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') })
|
||||
expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
@@ -68,6 +68,26 @@ interface RegisteredCommand {
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
/** All command registrations owned by one global or scoped layer. */
|
||||
class CommandLayer implements ScopeLayer {
|
||||
readonly commands: NamedEntries<RegisteredCommand>
|
||||
|
||||
/**
|
||||
* Create one command layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.commands = new NamedEntries(name => new Error(scope === undefined
|
||||
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no command registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.commands.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
|
||||
* globals for that agent.
|
||||
*/
|
||||
export class CommandService extends Service {
|
||||
private readonly global = new Map<string, RegisteredCommand>()
|
||||
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new CommandLayer(scope),
|
||||
() => { this.notifyChange() },
|
||||
)
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
@@ -218,25 +240,12 @@ export class CommandService extends Service {
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registered = normalizeDefinition(definition)
|
||||
const dispose = this.ctx.effect(function* (this: CommandService) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(registered.definition.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${registered.definition.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(registered.definition.name, registered)
|
||||
yield () => {
|
||||
layer.delete(registered.definition.name)
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.notifyChange()
|
||||
}
|
||||
this.notifyChange()
|
||||
}.bind(this), 'commands.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.commands.insert(registered.definition.name, registered),
|
||||
{ label: 'commands.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,19 +294,7 @@ export class CommandService extends Service {
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
private view(agent: Agent): Map<string, RegisteredCommand> {
|
||||
const visible = new Map(this.global)
|
||||
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
|
||||
return visible
|
||||
}
|
||||
|
||||
/** Create the registration layer for one agent scope on demand. */
|
||||
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
return this.layers.merge(agent, layer => layer.commands)
|
||||
}
|
||||
|
||||
/** Notify every registry observer without making UI refresh load-bearing. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user