Merge remote-tracking branch 'origin/master' into fix/web-fetch-ssrf

# Conflicts:
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl
This commit is contained in:
Dudu-0223
2026-08-24 21:42:27 +08:00
42 changed files with 1383 additions and 133 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
2026-07-08-tool-output-spill-files.md: 14667b74ca877622d05196e9bf83945a842fe366
2026-07-08-tool-output-spill-files.zh.md: db297fa6bee707a1d5a10d20260ce6b8a660d207
2026-07-08-tool-output-spill-files.md: e14607e388c634c4e2679c993c1b720be0a3a9f3
2026-07-08-tool-output-spill-files.zh.md: 372c9c6cadf3cd64c3de97a8c305b8909f03caab
@@ -57,7 +57,7 @@ interface SpillRef {
`SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy.
`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `<root>/session-<hash>/<random>-<safeName>`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path.
`dsh-spill-local` owns storage details: session-scoped directory selection, safe names, path-traversal protection, the write, local artifact lifetime, and returning `{ locator, bytes, retrievalHint }`. It does not own tool-result replacement, model-facing preview policy, search, file inspection, or a seam-wide/per-session retention policy. Files land at `<root>/session-<hash>/<random>-<safeName>`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path. Its one-shot startup cleanup applies the backend-specific artifact lifetime described in the [local spill cleanup note](./2026-07-17-local-spill-startup-cleanup.md).
### Spill policy
@@ -160,7 +160,8 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa
- Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL).
- Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient.
- Remote or database storage backends for ACP or remote environments where a local path is not meaningful.
- Cleanup and retention policy for old spill files, likely tied to session cleanup.
Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup Agent Note](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern.
## Testing
@@ -57,7 +57,7 @@ interface SpillRef {
`SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的 spill 定位符,无需复制它们或重新取得所有权;fork 后的新 spill 使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;spill seam 不定义逐会话的清理策略。
`dsh-spill-local` 负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索文件检查。文件写入 `<root>/session-<hash>/<random>-<safeName>``root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。
`dsh-spill-local` 负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入、本地产物生命周期,以及返回 `{ locator, bytes, retrievalHint }`。它不负责工具结果替换、模型可见的预览策略、搜索文件检查,也不定义 seam 级或逐会话保留策略。文件写入 `<root>/session-<hash>/<random>-<safeName>``root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。它的一次性启动清理会应用[本地 spill 清理说明](./2026-07-17-local-spill-startup-cleanup.zh.md)所述的后端专属产物生命周期。
### spill 策略
@@ -160,7 +160,8 @@ ctx.tools.register(defineTool({
- 由工具负责的 subagent 执行轨迹 spill`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。
- 如果内置的 `read` 跳过规则不足,再增加逐工具选择退出或逐工具策略声明。
- 面向 ACPAgent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。
- 旧 spill 文件的清理和保留策略,很可能与会话清理绑定。
本地后端通过一次性启动扫描清理旧文件,而不是绑定到会话删除——参见[启动清理 Agent Note](./2026-07-17-local-spill-startup-cleanup.zh.md)。seam 仍未定义逐会话清理策略;保留策略属于后端。
## 测试
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md
2026-07-17-local-spill-startup-cleanup.md: fc64938c1af07d9dd0d7ecec379115d22d1e2464
2026-07-17-local-spill-startup-cleanup.zh.md: 583a33ead84f552c67e2e770a8b3fabc3ce88120
@@ -0,0 +1,37 @@
# Agent Note: One-shot startup cleanup for local spill files
Status: implemented
English | [中文](2026-07-17-local-spill-startup-cleanup.zh.md)
## Problem
The local spill backend never deleted the full tool results it wrote. Every oversized result added another file, so configured roots grew without bound and default per-process `dsh-spill-*` roots accumulated across runs. Immediate deletion is wrong because persisted, resumed, and forked sessions may still reference a locator. The [tool output spill policy](./2026-07-08-tool-output-spill-files.md) needs a bounded local-storage lifetime.
## Decision
`dsh-spill-local` runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single `ctx.effect` whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process.
A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. Schemastery rejects a negative or fractional value at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir and deletes regular files whose `mtime` is strictly older than `now cleanupPeriodDays`. It prunes every empty session directory but removes the root itself only for a discovered prior-default root; writes recreate a session directory if pruning races them. Root aliases are de-duplicated by device/inode identity, with the configured identity overriding a discovered match as active and non-prunable. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn`, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write.
Path-based deletion is restricted to directories an untrusted local OS user cannot replace during the scan. On POSIX, every root and session directory must be owned by the current user and not writable by group or others; the root's ancestor path must also be non-writable or protected by a sticky directory such as `/tmp`. Discovery rejects symlinks, while a configured symlink may resolve to a trusted target and participates in identity de-duplication. An unsafe path is skipped with a warning. The same-user account remains the trust boundary, consistent with the backend's private local-storage model.
The ctx-free sweep mechanics live in `packages/spill/spill-local/src/cleanup.ts` (`sweepSpillRoots`, `discoverDefaultRoots`), unit-testable without a `ctx`; `store.ts` owns root naming, path derivation, and writes, while the service in `src/index.ts` owns the config, cutoff, and fiber-owned launch/await.
## Alternatives considered
**Run a periodic timer.** Rejected because it adds timer lifecycle, overlap control, and another interval knob. A long-lived process may retain files until restart.
**Delete spills on session disposal.** Rejected because durable sessions, resumes, and forks retain locators.
**Delete old session directories recursively.** Rejected because a concurrent process may create a fresh spill after the age check. Per-file expiry preserves fresh writes.
**Tie cleanup to session-persistence deletion.** Rejected because the persistence seam has no common deletion lifecycle, while the local backend also owns independent temporary roots.
## Consequences
Cleanup cost the backend a startup sweep and a config knob, and bought a bounded local-storage lifetime without a timer, a daemon, or a session-lifecycle coupling. Concurrent processes may duplicate startup I/O; strict filtering and idempotent file deletion keep this safe. A long-lived process is not cleaned again until restart, and retention deliberately makes old model-visible locators stale only once they age past the cutoff. The seam itself still defines no retention policy — this is a local-backend concern.
## Testing
`dsh-spill-local` unit tests cover the exact age boundary, `cleanupPeriodDays: 0` disabling, empty-session and discovered-root pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage, filesystem-identity de-duplication through a configured symlink, unsafe POSIX root/session rejection, load-time config validation, filesystem- and warning-sink-failure containment, and the quiescence contract. A separate test boots the plugin through the real Loader and a cordis.yml, then observes configured expiry and directory pruning after disposal.
@@ -0,0 +1,37 @@
# Agent Note: 本地 spill 文件的一次性启动清理
Status: implemented
[English](2026-07-17-local-spill-startup-cleanup.md) | 中文
## 问题
本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.zh.md)需要一个有界的本地存储生命周期。
## 决策
`dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。
`cleanupPeriodDays` 配置默认为 `30``0` 会禁用清理。Schemastery 会在加载时拒绝负数或小数。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,并删除 `mtime` 严格早于 `now cleanupPeriodDays` 的常规文件。它会修剪所有空会话目录,但只删除发现的先前默认根目录本身;如果修剪与写入发生竞争,写入操作会重新创建会话目录。根目录别名按设备/inode 身份去重,配置目录的身份会覆盖发现的匹配项,并标记为活动且不可删除。扫描使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录,警告接收方抛出的异常也会被兜底——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。
基于路径的删除仅限于不受信任的本地 OS 用户无法在扫描期间替换的目录。在 POSIX 上,每个根目录和会话目录都必须由当前用户拥有,且组用户和其他用户不可写;根目录的祖先路径也必须不可写,或由 `/tmp` 这类 sticky 目录保护。发现过程拒绝符号链接,而配置的符号链接可以解析到可信目标并参与身份去重。不安全路径会被跳过并记录警告。与后端的私有本地存储模型一致,同一用户账号仍是信任边界。
无 ctx 依赖的扫描机制位于 `packages/spill/spill-local/src/cleanup.ts``sweepSpillRoots``discoverDefaultRoots`),无需 `ctx` 即可做单元测试;`store.ts` 负责根目录命名、路径推导与写入,而 `src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。
## 考虑过的替代方案
**运行周期性定时器。** 已否决,因为它引入了定时器生命周期、重叠控制以及又一个间隔旋钮。长期运行的进程可能会保留文件直到重启。
**在会话 dispose 时删除 spill。** 已否决,因为持久会话、恢复和 fork 都会保留 locator。
**递归删除旧的会话目录。** 已否决,因为并发进程可能在年龄检查之后创建一个新的 spill。按文件过期可保留新写入。
**将清理绑定到会话持久化删除。** 已否决,因为持久化 seam 没有共同的删除生命周期,而本地后端还独立拥有临时根目录。
## 后果
清理让后端付出了一次启动扫描和一个配置旋钮的代价,换来了无需定时器、守护进程或会话生命周期耦合的有界本地存储生命周期。并发进程可能重复启动 I/O;严格的过滤与幂等的文件删除保证了这一点的安全。长期运行的进程在重启前不会再次被清理,而这种保留是刻意的——旧的模型可见 locator 只有在超过截止时间后才会失效。seam 本身仍不定义任何保留策略——这是本地后端的关切。
## 验证
`dsh-spill-local` 单元测试覆盖了精确年龄边界、`cleanupPeriodDays: 0` 的禁用、空会话目录与发现根目录的修剪、符号链接/无关条目的跳过、配置根加发现根的覆盖、经配置符号链接验证的文件系统身份去重、不安全 POSIX 根目录/会话目录拒绝、加载期配置校验、文件系统与警告接收方故障兜底,以及静止契约。另一个测试会通过真实 Loader 和 cordis.yml 启动插件,并在 dispose 后观察按配置执行的过期与目录修剪。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md
2026-07-20-config-hot-reload-resilience.md: 2b98ee73e1c7734da9c0ae6df5e3de1af6a5cbeb
2026-07-20-config-hot-reload-resilience.zh.md: 0a3652a7cac589b601d7fd30d0c713d4585be830
2026-07-20-config-hot-reload-resilience.md: 6f8503c93bcbf55b1bd0543cf49e41cfe32fbe2f
2026-07-20-config-hot-reload-resilience.zh.md: 359a34a2252b2999f896245a4bd07f7da32d98f5
@@ -38,4 +38,4 @@ HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` met
## Testing
`packages/boot/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/boot/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.
`packages/boot/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/boot/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, and `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer.
@@ -38,4 +38,4 @@ HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方
## Testing
`packages/boot/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/boot/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除ACPAgent Client Protocol)的 `pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序
`packages/boot/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/boot/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md
2026-08-24-system-prompt-section-order-ties.md: 673c3b3cd668115ead9f9b5478c2bc432b78f930
2026-08-24-system-prompt-section-order-ties.zh.md: 96a6843a0db48e52a2132ad9f8caf6243dfbbcd2
@@ -0,0 +1,27 @@
# Agent Note: Equal-order system-prompt sections render in activation order
Status: implemented
English | [中文](2026-08-24-system-prompt-section-order-ties.zh.md)
## Problem
`SystemPromptRegistry` sorts sections by `order` with a stable sort, so equal orders render in plugin-activation order. `tool:cordis` and `tool:workflow` both declared `order: 115`, while their activation order varies between clean platform compositions. ACP and SDK snapshot replays could therefore assemble the same sections in a different order from their committed `system-prompt.expected.md` files.
## Decision
Give the affected sequence distinct values without changing its established relative order: `tool:cordis` stays at 115, `tool:workflow` uses 115.5, `tool:ralph` stays at 116, continuable subagent guidance stays at 116.5, and child-report guidance stays at 117. Prompt text and tool schemas remain unchanged.
## Alternatives considered
**Normalize section order in the snapshot harness.** Rejected because the runtime, request header, and model prompt would remain sensitive to activation timing while only the fixture comparison hid the difference.
**Tie-break equal orders by section name in the registry.** Rejected because it would silently reorder every existing tie. Explicit orders keep each model-visible placement local to the contributing plugin.
## Consequences
The Cordis and workflow guidance has a platform-independent order while Ralph remains before continuable subagent and child-report guidance. Prompt-section placements that require a stable relative position need distinct `order` values; other equal-order sections retain activation-order semantics and are outside this decision.
## Testing
The keyless ACP and SDK snapshot replays pin Cordis before workflow and preserve the workflow, Ralph, continuable-subagent, and child-report sequence. The full snapshot suite verifies the refreshed fixtures.
@@ -0,0 +1,27 @@
# Agent Note: 等序系统提示词分段按激活顺序渲染
Status: implemented
[English](2026-08-24-system-prompt-section-order-ties.md) | 中文
## Problem
`SystemPromptRegistry` 使用稳定排序按 `order` 排列分段,因此相同 order 的分段会按插件激活顺序渲染。`tool:cordis``tool:workflow` 都声明了 `order: 115`,但两者在不同平台的全新组合中激活顺序不同。因此,ACPAgent Client Protocol)与 SDK 的快照回放可能把相同分段组装成不同于已提交 `system-prompt.expected.md` 文件的顺序。
## Decision
在不改变既有相对顺序的前提下,为受影响的分段序列指定互不相同的 order:`tool:cordis` 保持 115`tool:workflow` 使用 115.5`tool:ralph` 保持 116,可继续运行的子代理指引保持 116.5,子代理报告指引保持 117。提示词文本与工具 schema 保持不变。
## Alternatives considered
**在快照 harness 中规范化分段顺序。** 已否决,因为运行时、请求标头和模型提示词仍然受激活时序影响,只有 fixture 比较会隐藏差异。
**在注册表中用分段名称打破并列。** 已否决,因为这会静默重排每一组现有并列。显式 order 让每个模型可见位置都由贡献该分段的插件就地决定。
## Consequences
Cordis 与 workflow 指引具有不依赖平台的顺序,同时 Ralph 仍排在可继续运行的子代理指引和子代理报告指引之前。需要稳定相对位置的提示词分段必须使用互不相同的 `order`;其他等序分段仍采用激活顺序,不属于本决策的范围。
## Testing
无密钥 ACP 与 SDK 快照回放会固定 Cordis 排在 workflow 之前,并保留 workflow、Ralph、可继续运行的子代理和子代理报告指引的顺序。完整快照套件验证刷新的 fixture。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 2b33b57b9ad7b0284a765a635a4b35151f32cf15
config-catalog.zh.md: 3f2c6545348e7e784cc50f34e0523e15509ed7ea
config-catalog.md: 131c3a793eb30f10f4949cf83b4af07acb61ac96
config-catalog.zh.md: 3ce3dac965afad296324a80f72fb612359f6f76c
+12 -1
View File
@@ -2094,10 +2094,21 @@ export interface Config {
* a local deployment. Set it to keep spill files under a known location.
*/
root?: string
/**
* Age in days after which a spill file is eligible for the one-shot startup
* cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose
* `mtime` is strictly older than the cutoff are deleted and emptied
* directories are pruned; fresh files, symlinks, and unrelated entries are
* left untouched. On POSIX, cleanup skips roots and session directories that
* another local user could modify or replace. Retention is deliberate — a
* resumed or forked session may still reference an older locator until it
* ages out.
*/
cleanupPeriodDays?: number
}
```
Source: [`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts)
Source: [`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-local/src/index.ts)
<a id="deepseek-aidsh-spill-policy"></a>
+12 -1
View File
@@ -2096,10 +2096,21 @@ export interface Config {
* a local deployment. Set it to keep spill files under a known location.
*/
root?: string
/**
* Age in days after which a spill file is eligible for the one-shot startup
* cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose
* `mtime` is strictly older than the cutoff are deleted and emptied
* directories are pruned; fresh files, symlinks, and unrelated entries are
* left untouched. On POSIX, cleanup skips roots and session directories that
* another local user could modify or replace. Retention is deliberate — a
* resumed or forked session may still reference an older locator until it
* ages out.
*/
cleanupPeriodDays?: number
}
```
来源:[`packages/spill/spill-local/src/index.ts:22`](../packages/spill/spill-local/src/index.ts)
来源:[`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-local/src/index.ts)
<a id="deepseek-aidsh-spill-policy"></a>
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"7fdea9c7-84a0-42cd-a6e7-87970eec96f8"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"afbf8f72-cae0-4794-8be6-37452405c7c4"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"bd117979-2f64-4c0e-be05-fab637a29f65"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"369bc0b7-d962-4fa3-ad26-2d12e9221ce5"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -23,8 +23,6 @@ Use the web_search tool to discover current information on the web. The required
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
# Dynamic Cordis Plugins
Dynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.
@@ -131,6 +129,8 @@ return {
- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.
- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth one"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d4e372aa-55e6-449e-866c-304a40636960"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"e5d3e23a-5da9-4fbc-b1d6-191c4695f836"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Call subagent once. Ask that","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Start depth two"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"b1738d0c-664f-4b03-8f24-f03771443bfa"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d45f3c3e-0ece-4a91-a51d-fec64eb3a12c"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Attempt one subagent call beyond","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"32f16ad5-f948-46a2-b9cf-527f4706814e"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"56731604-043c-4e02-9e5d-1c5be29112a8"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -31,7 +31,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"a8250046-5eec-432b-9cf0-f98dc7bb2a78"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"ad40f1f5-0002-4d06-a7bd-075681aae80c"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
{"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}}
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"179b4d15-5fd1-4435-8afd-eaba6704e873"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"96f6b71a-68f2-4bbb-9c27-d2d502b1a9ff"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -8,7 +8,7 @@
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"932f9dd2-9e56-4a4c-908f-222fc4e77361"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"0b1bbd3e-1a47-412a-b5cd-cc759efb6c46"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -2,19 +2,19 @@
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d006448b-0f3a-42d2-aba3-8a12729c8642"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a04ea19b-107f-4982-9463-bf2e8b1d4839"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d006448b-0f3a-42d2-aba3-8a12729c8642"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"60579749-d17e-44c1-8d13-a355fb2ecc13"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a04ea19b-107f-4982-9463-bf2e8b1d4839"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"2f3f59d5-2322-46c7-8204-ec15fb1d6535"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b73f99d-a6bc-46b6-9234-6bf3b50ebcb1"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73c7833f-b67c-4ba2-9062-ec176c471797"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -2,19 +2,19 @@
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8f90fcde-315a-47c9-9491-a9632a353751"}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"e99a8264-b845-4cbc-a59e-c13017385d5d"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8f90fcde-315a-47c9-9491-a9632a353751"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"8928a20a-8d83-40b4-a97f-c97b976b4f9a"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"e99a8264-b845-4cbc-a59e-c13017385d5d"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"5df936fd-b254-4520-9cc4-fe52d72e3b8f"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16edd5ce-18cd-45a3-be15-b80226508a7d"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8d556ede-e476-432b-a316-d033099c0671"}},"sourceEventSeqs":[13,14,15],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/spill/spill-local/README.md
README.md: 2270a65d9270e1549a9e48d6a36b821e48c29070
README.zh.md: b3e4999d8f2d982ef01637199299f79d06e12c4b
README.md: e5a1fb08ba438640e649319f42d31aa10afd24c1
README.zh.md: ba6e2b3c4628b7a21de1361fcd2d1e0a1b573f4b
+11 -2
View File
@@ -17,8 +17,17 @@ Files land at `<root>/session-<hash>/<random>-<safeName>`:
| Key | Default | Meaning |
|---|---|---|
| `root` | private 0700 temp dir | Root directory for spill files. Set to keep them under a known location. |
| `cleanupPeriodDays` | `30` | Age in days after which a spill file is eligible for the one-shot startup cleanup sweep. `0` disables cleanup. |
`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design.
## Startup cleanup
The backend never deletes a spill on the write path — a persisted, resumed, or forked session may still reference an older locator, so immediate deletion would break retrieval. Instead, one best-effort sweep runs **once after activation**: it does not delay service availability, is owned by the plugin fiber, and is awaited on disposal (no sweep I/O outlives the fiber). There is no recurring timer and no separate process, so a long-lived deployment is not swept again until its next restart.
The sweep scans the configured `root` **and** any earlier default `dsh-spill-*` temp roots that prior default-root runs left under the OS temp dir. It resolves each root to its filesystem identity, so a configured alias of a discovered root remains the active, non-prunable root. Within each root, the sweep deletes regular files whose `mtime` is strictly older than `now cleanupPeriodDays` and prunes every empty session directory; only an empty discovered prior-default root is itself removed. A write recreates a session directory if cleanup races it. The sweep never follows or deletes a symlink and skips unrelated entries.
On POSIX, cleanup admits only roots owned by the current user, not writable by group or others, and protected from replacement through their ancestor path; a writable sticky temporary directory such as `/tmp` is permitted. Session directories must satisfy the same ownership and write restrictions. Unsafe paths are skipped with a warning, which prevents an untrusted local process from redirecting path-based deletion outside the spill root. Every filesystem or warning-sink failure is contained, so cleanup cannot fail activation or a concurrent spill write. Retention is deliberate: an old model-visible locator goes stale only once it ages past the cutoff.
`saveText` rejects on a real storage failure (permissions, ENOSPC); the spill policy treats a rejection as best-effort and keeps the inline result. See the seam README for the vocabulary and the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) for the design, and the [startup-cleanup Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md) for the sweep.
## Model Experience
@@ -30,5 +39,5 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path.
- **A long-lived deployment is not swept until restart** — the one-shot sweep runs once after activation, so files that age past `cleanupPeriodDays` mid-run are reclaimed only on the next start; there is no recurring timer.
- **Locators require a co-located filesystem consumer** — a remote or virtual deployment needs another `SpillStore` backend whose locator and retrieval hint are meaningful there.
+11 -2
View File
@@ -17,8 +17,17 @@
| 键 | 默认值 | 含义 |
|---|---|---|
| `root` | 私有 0700 临时目录 | spill 文件的根目录。设置后可将这些文件保存在已知位置。 |
| `cleanupPeriodDays` | `30` | spill 文件在一次性启动清理扫描中符合删除条件前需经过的天数。`0` 禁用清理。 |
`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)。
## 启动清理
后端不会在写入路径上删除 spill,因为已持久化、已恢复或 fork 后的会话仍可能引用较旧的定位信息,立即删除会使其无法取回。后端会改为在激活后**仅运行一次**尽力而为的扫描:扫描不延迟服务可用性,由插件 fiber 拥有,并在 dispose 期间被等待(不会有扫描 I/O 存活至 fiber 之后)。它既不使用周期性定时器,也不运行独立进程,因此长期运行的部署要到下次重启才会再次扫描。
扫描会检查配置的 `root` **以及**先前使用默认根目录的运行在操作系统临时目录下留下的所有 `dsh-spill-*` 临时根目录。它会把每个根目录解析为文件系统身份,因此当配置路径是某个已发现根目录的别名时,该目录仍会作为不可删除的活动根目录处理。在每个根目录中,扫描会删除 `mtime` 严格早于 `now cleanupPeriodDays` 的常规文件并修剪所有空会话目录;只有发现的先前默认根目录会在变空后被删除。如果清理与写入发生竞争,写入操作会重新创建会话目录。扫描绝不会跟随或删除符号链接,并会跳过无关条目。
在 POSIX 上,清理只接受由当前用户拥有、组用户和其他用户不可写、且祖先路径可防止他人替换的根目录;`/tmp` 这类带 sticky 位的可写临时目录仍然允许使用。会话目录必须满足相同的所有权和写权限限制。不安全路径会被跳过并记录警告,从而防止不受信任的本地进程把基于路径的删除重定向到 spill 根目录之外。所有文件系统故障和警告接收方故障都会被兜底,因此清理无法使激活或并发 spill 写入失败。保留是刻意的:旧的模型可见定位信息只有超过截止时间后才会失效。
`saveText` 在发生真实存储故障(权限、ENOSPC)时返回拒绝;spill 策略会按尽力而为原则处理该拒绝,并保留内联结果。词汇见 seam README,设计见[工具输出 spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md),扫描机制见[启动清理 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md)。
## 模型体验
@@ -30,5 +39,5 @@
## 已知限制与暂缓事项
- **本地 spill 文件会持续存在,直到外部清理为止**:该后端不提供会话生命周期删除或按时间保留的策略,因为已持久化、已恢复和 fork 后的会话可能仍在引用某个路径
- **长期运行的部署需等到重启才会被扫描**:一次性扫描仅在激活后运行一次,因此运行期间达到 `cleanupPeriodDays` 的文件要到下次启动才会被回收;没有周期性定时器
- **定位信息需要与其位于同一文件系统的消费方**:远程或虚拟部署需要另一个 `SpillStore` 后端,其定位信息和取回指引在该环境中有明确含义。
+2
View File
@@ -40,6 +40,8 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+431
View File
@@ -0,0 +1,431 @@
/** Startup cleanup mechanics for local spill roots. */
import { lstat, readdir, realpath, rmdir, unlink } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { DEFAULT_ROOT_PREFIX, isErrno } from './store.ts'
/**
* A backend-generated default root name: `dsh-spill-` plus the 6-character
* suffix `mkdtemp` appends. Discovery matches this
* EXACT shape, not the bare prefix, so an unrelated `dsh-spill-test-*` fixture
* or a foreign tool's differently-shaped `dsh-spill-…` directory is never
* mistaken for a backend root to sweep.
*/
const DEFAULT_ROOT_RE = new RegExp(`^${DEFAULT_ROOT_PREFIX}[A-Za-z0-9]{6}$`)
/**
* A backend-generated session directory name: `session-` plus the 12 lowercase
* hex characters {@link sessionDir} derives from `sha256(sessionId)`. The sweep
* only descends into entries of this EXACT shape, so an unrelated
* `session-backup` directory under a shared configured root is never swept.
*/
const SESSION_DIR_RE = /^session-[0-9a-f]{12}$/
/** An existing root resolved to one stable filesystem identity. */
interface ResolvedRoot {
/** Canonical absolute path used for the sweep. */
path: string
/** Device/inode identity used to de-duplicate filesystem aliases. */
identity: string
}
/** A one-argument warning sink — the sweep's only side effect on failure (never throws). */
export type WarnFn = (message: string) => void
/** Report a best-effort sweep failure without allowing the warning sink to reject cleanup. */
function warnSafely(warn: WarnFn, message: string): void {
try {
warn(message)
} catch {
// Warning sinks are observational callbacks; cleanup must remain best-effort
// even when a logger implementation throws.
}
}
/** Whether another local OS user cannot replace children of this directory. */
function isTrustedDirectory(stats: Stats): boolean {
if (!stats.isDirectory()) return false
/* v8 ignore next -- POSIX ownership and mode bits have no Windows equivalent. */
if (process.platform === 'win32' || process.geteuid === undefined) return true
/* v8 ignore start -- Windows takes the return above; POSIX tests exercise
owner and mode rejection. */
return stats.uid === process.geteuid() && (stats.mode & 0o022) === 0
/* v8 ignore stop */
}
/** Stable identity for de-duplicating aliases of one root. */
function rootIdentity(path: string, stats: Stats): string {
/* v8 ignore next -- Windows file indexes are not portable inode identities. */
if (process.platform === 'win32') return path.toLowerCase()
/* v8 ignore start -- Windows uses the canonical path identity above; POSIX
tests exercise device and inode identity. */
return `${String(stats.dev)}:${String(stats.ino)}`
/* v8 ignore stop */
}
/**
* Check that no ancestor permits another local OS user to replace the selected
* child. A sticky writable ancestor is safe because the child is owned by the
* current user; this admits normal per-process roots below `/tmp`.
*/
async function hasProtectedAncestors(path: string): Promise<boolean> {
/* v8 ignore next -- POSIX ancestry checks have no Windows ACL equivalent. */
if (process.platform === 'win32' || process.geteuid === undefined) return true
/* v8 ignore start -- Windows takes the return above; POSIX tests exercise
the ancestor ownership and mode policy. */
const currentUid = process.geteuid()
let child = path
let childStats = await lstat(child)
for (;;) {
const parent = dirname(child)
if (parent === child) return true
const stats = await lstat(parent)
/* v8 ignore next -- every ancestor of a successfully resolved path is a directory. */
if (!stats.isDirectory()) return false
const writableByOthers = (stats.mode & 0o022) !== 0
const sticky = (stats.mode & 0o1000) !== 0
if (writableByOthers && !sticky) return false
/* v8 ignore next -- requires an ancestor owned by another OS account inside
a writable sticky parent; ordinary test fixtures cannot change uid. */
if (writableByOthers && childStats.uid !== currentUid) return false
child = parent
childStats = stats
}
/* v8 ignore stop */
}
/**
* Resolve one existing root without admitting a directory another local user
* can replace during the path-based sweep. A configured root may be a symlink;
* discovery passes `false` so a symlink cannot impersonate a default root.
*
* @param path Candidate root path.
* @param allowSymlink Whether the candidate itself may be a configured symlink.
* @param warn Sink for skipped or failed inspection.
* @returns The trusted canonical root, or `undefined` when it is absent or unsafe.
*/
async function resolveRoot(path: string, allowSymlink: boolean, warn: WarnFn): Promise<ResolvedRoot | undefined> {
let initial: Stats
try {
initial = await lstat(path)
} catch (error: unknown) {
/* v8 ignore start -- non-ENOENT inspection failures depend on host ACL or
an entry racing away and cannot be reproduced portably. */
if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to inspect root ${path}: ${String(error)}`)
return undefined
/* v8 ignore stop */
}
if (initial.isSymbolicLink()) {
if (!allowSymlink) return undefined
} else if (!isTrustedDirectory(initial)) {
warnSafely(warn, `spill-local: skipped unsafe root ${path}: expected a directory owned by the current user and not writable by group or others`)
return undefined
}
let canonical: string
let stats: Stats
try {
canonical = await realpath(path)
stats = await lstat(canonical)
} catch (error: unknown) {
/* v8 ignore start -- a root lstat'd above reaches this only by racing away
or by a host-specific realpath failure. */
if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to resolve root ${path}: ${String(error)}`)
return undefined
/* v8 ignore stop */
}
let protectedAncestors = false
try {
protectedAncestors = await hasProtectedAncestors(canonical)
} catch (error: unknown) {
/* v8 ignore start -- a canonical ancestor disappears only through a race;
other failures depend on host ACLs. */
if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to inspect ancestors of root ${canonical}: ${String(error)}`)
return undefined
/* v8 ignore stop */
}
/* v8 ignore start -- Windows has no POSIX ownership or mode rejection path;
POSIX tests exercise both unsafe-directory conditions. */
if (!isTrustedDirectory(stats) || !protectedAncestors) {
warnSafely(warn, `spill-local: skipped unsafe root ${canonical}: expected a current-user-owned directory with protected write and ancestor permissions`)
return undefined
}
/* v8 ignore stop */
return { path: canonical, identity: rootIdentity(canonical, stats) }
}
/** One root to sweep, plus whether the root itself may be pruned once empty. */
export interface SweepRoot {
/** Absolute spill root to sweep. */
path: string
/**
* When `true`, remove the root after its empty `session-*` children are
* pruned. Set for DISCOVERED prior-default `dsh-spill-*` roots (one per past
* process otherwise they accumulate empty forever), never for the active
* root the live process is still writing into. Every root prunes empty session
* directories; writes retry if that races their removal.
*/
pruneWhenEmpty: boolean
}
/** Options for {@link sweepSpillRoots} — the roots to scan, the age cutoff, and a failure sink. */
export interface SweepOptions {
/** Roots to sweep (configured/active root and/or discovered prior-default roots). */
roots: SweepRoot[]
/**
* Epoch-millis cutoff: a regular file is deleted when its `mtime` is strictly
* older than this. The caller derives it from `now - cleanupPeriodDays`, so a
* file written exactly at the boundary is kept (only strictly-older expires).
*/
cutoffMs: number
/** Where a contained filesystem failure is reported; the sweep itself never throws. */
warn: WarnFn
}
/**
* Delete a single path, treating a concurrent-race disappearance as success.
* A parallel process (or another sweep) may `unlink` the same file between our
* scan and our own `unlink` ENOENT then means the goal (file gone) already
* holds, so it is not a failure. Any other error is reported and swallowed.
*
* @param path The absolute file path to remove.
* @param warn Sink for a non-ENOENT failure message.
* @returns Resolves once the removal was attempted (never rejects).
*/
async function unlinkIdempotent(path: string, warn: WarnFn): Promise<void> {
try {
await unlink(path)
} catch (error: unknown) {
/* v8 ignore start -- reached only when a file selected for deletion (a
regular file that passed lstat) then fails to unlink: either it raced away
(ENOENT) or a permission/IO fault struck between the stat and the unlink.
Neither is deterministically reproducible in-process. */
if (isErrno(error, 'ENOENT')) return
warnSafely(warn, `spill-local: failed to delete ${path}: ${String(error)}`)
/* v8 ignore stop */
}
}
/**
* Sweep one spill session directory: delete expired regular files, skip
* everything else, and report the directory empty afterward so the caller can
* prune it. The `dir` entry MUST be a real directory the caller `lstat`s it
* first and skips a symlink, so this never follows a `session-*` symlink into a
* foreign tree. Inside, a symlink or any non-regular entry (socket, fifo, nested
* dir) is left untouched `lstat` never follows a link, so a planted symlink
* can neither be deleted nor redirect the age check. Every per-entry failure is
* contained: one unreadable file does not abort the directory.
*
* @param dir The absolute session directory to scan (already confirmed a real dir).
* @param cutoffMs Files with `mtime` strictly older than this are deleted.
* @param warn Sink for contained filesystem failures.
* @returns `true` when the directory holds no entries after the sweep (a prune candidate).
*/
async function sweepSessionDir(dir: string, cutoffMs: number, warn: WarnFn): Promise<boolean> {
let names: string[]
try {
names = await readdir(dir)
} catch (error: unknown) {
/* v8 ignore start -- the caller lstat'd this entry and confirmed a real
directory just before the call, so readdir fails only when the dir races
away (ENOENT) or a permission/IO fault strikes in that window; not
deterministically reproducible. False keeps it out of the prune step. */
warnSafely(warn, `spill-local: failed to read ${dir}: ${String(error)}`)
return false
/* v8 ignore stop */
}
let remaining = names.length
for (const name of names) {
const path = join(dir, name)
let stats
try {
stats = await lstat(path)
} catch (error: unknown) {
/* v8 ignore start -- an entry that readdir just returned then fails to
lstat only by racing away (ENOENT) or a permission/IO fault; keep it out
of the deterministic test surface. */
if (isErrno(error, 'ENOENT')) { remaining--; continue }
warnSafely(warn, `spill-local: failed to stat ${path}: ${String(error)}`)
continue
/* v8 ignore stop */
}
// Only regular files expire. Symlinks and other special entries are skipped
// (never followed) so the sweep cannot be redirected or delete a link.
if (!stats.isFile()) continue
if (stats.mtimeMs >= cutoffMs) continue
await unlinkIdempotent(path, warn)
remaining--
}
return remaining === 0
}
/**
* Best-effort one-shot cleanup: across each root, delete expired regular files
* under its `session-*` directories and prune every empty session directory.
* Only a discovered prior-default root is itself removed. Writes recreate a
* session directory when pruning races a local write. Every filesystem and
* warning-sink failure is contained, so a caller can await this during
* activation/disposal without it ever rejecting.
*
* @param options The roots to sweep, the age cutoff, and the failure sink.
* @returns Resolves when the sweep finishes (never rejects).
*/
export async function sweepSpillRoots(options: SweepOptions): Promise<void> {
const { cutoffMs, warn } = options
const roots = new Map<string, SweepRoot>()
for (const candidate of options.roots) {
const resolved = await resolveRoot(candidate.path, false, warn)
if (resolved === undefined) continue
const existing = roots.get(resolved.identity)
roots.set(resolved.identity, {
path: resolved.path,
pruneWhenEmpty: (existing?.pruneWhenEmpty ?? true) && candidate.pruneWhenEmpty,
})
}
for (const root of roots.values()) {
let entries: string[]
try {
entries = await readdir(root.path)
} catch (error: unknown) {
// A root that does not exist yet (no spill ever written) is the common
// case, not an error: ENOENT is silent, anything else is reported.
/* v8 ignore start -- the trusted root was resolved immediately above; a
read failure now requires a race or host-specific ACL fault. */
if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to read root ${root.path}: ${String(error)}`)
continue
/* v8 ignore stop */
}
// Track whether the root holds ANY entry the sweep did not fully reclaim, so
// a discovered prior-default root can be pruned only when nothing remains.
let rootEmptiable = true
for (const name of entries) {
// Only the backend's own `session-<12 hex>` directories are swept; an
// unrelated sibling (`session-backup`, a stray file) is left untouched and
// blocks pruning the root.
if (!SESSION_DIR_RE.test(name)) { rootEmptiable = false; continue }
const dir = join(root.path, name)
let stats
try {
// lstat the session entry itself: a `session-*` SYMLINK must never be
// followed (readdir/unlink through it would delete files in a foreign
// target). Only a real directory is swept.
stats = await lstat(dir)
} catch (error: unknown) {
/* v8 ignore start -- an entry readdir just returned fails to lstat only
by racing away (ENOENT) or a permission/IO fault; not deterministically
reproducible. */
if (!isErrno(error, 'ENOENT')) warnSafely(warn, `spill-local: failed to stat ${dir}: ${String(error)}`)
continue
/* v8 ignore stop */
}
if (!isTrustedDirectory(stats)) {
warnSafely(warn, `spill-local: skipped unsafe session directory ${dir}`)
rootEmptiable = false
continue
}
const empty = await sweepSessionDir(dir, cutoffMs, warn)
if (!empty) { rootEmptiable = false; continue }
try {
await rmdir(dir)
} catch (error: unknown) {
/* v8 ignore start -- prune runs only on a dir observed empty; a failure
here means a concurrent writer added a file (ENOTEMPTY) or a
permission/IO fault struck both are races outside deterministic
in-process testing. */
rootEmptiable = false
if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) {
warnSafely(warn, `spill-local: failed to prune ${dir}: ${String(error)}`)
}
/* v8 ignore stop */
}
}
// A discovered prior-default root (one per past process) is removed once its
// last session dir is gone — otherwise empty roots accumulate forever and
// every future startup rescans them. The active root itself is never pruned.
if (root.pruneWhenEmpty && rootEmptiable) {
try {
await rmdir(root.path)
} catch (error: unknown) {
/* v8 ignore start -- prune runs only on a root whose every child was
reclaimed; a failure here means a concurrent writer added a fresh
spill after our scan (ENOTEMPTY) or removed the root already (ENOENT)
or a permission/IO fault struck all races outside deterministic
in-process testing. */
if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) {
warnSafely(warn, `spill-local: failed to prune root ${root.path}: ${String(error)}`)
}
/* v8 ignore stop */
}
}
}
}
/**
* Discover prior default spill roots: the `dsh-spill-<6 chars>` directories
* directly under `base` (the OS tmpdir) that earlier default-root runs created.
* A long-lived deployment
* with a configured root will find none; a series of default-root runs
* accumulates one per process, so the startup sweep reclaims them all. Matching
* is the EXACT `mkdtemp` shape (see {@link DEFAULT_ROOT_RE}), not the bare
* prefix, so an unrelated `dsh-spill-test-*` fixture or a foreign
* differently-shaped directory is never swept; symlinks and non-directories are
* excluded too only real directories the backend could have created.
*
* @param warn Sink for a failure reading `base` (returns `[]` on failure).
* @param base The directory to scan; defaults to the OS tmpdir (a test seam).
* @returns Absolute paths of the discovered default roots (possibly empty).
*/
async function discoverDefaultRootRecords(warn: WarnFn, base: string): Promise<ResolvedRoot[]> {
let entries: string[]
try {
entries = await readdir(base)
} catch (error: unknown) {
warnSafely(warn, `spill-local: failed to scan ${base} for default roots: ${String(error)}`)
return []
}
const roots: ResolvedRoot[] = []
for (const name of entries) {
if (!DEFAULT_ROOT_RE.test(name)) continue
const path = join(base, name)
const resolved = await resolveRoot(path, false, warn)
if (resolved !== undefined) roots.push(resolved)
}
return roots
}
/**
* Discover trusted prior default roots below the OS temporary directory.
*
* @param warn Sink for contained discovery failures.
* @param base Directory to scan; defaults to the OS temporary directory.
* @returns Canonical paths of trusted default roots.
*/
export async function discoverDefaultRoots(warn: WarnFn, base: string = tmpdir()): Promise<string[]> {
return (await discoverDefaultRootRecords(warn, base)).map(root => root.path)
}
/**
* Gather and de-duplicate the trusted roots for one startup sweep. The active
* configured path may be a symlink; its resolved identity overrides a matching
* discovered root so the live target is never marked prunable.
*
* @param activeRoot Active configured root.
* @param warn Sink for contained inspection failures.
* @param defaultRootsBase Directory holding prior default roots.
* @returns Trusted roots with the active identity marked non-prunable.
*/
export async function gatherSweepRoots(
activeRoot: string,
warn: WarnFn,
defaultRootsBase: string = tmpdir(),
): Promise<SweepRoot[]> {
const [discovered, active] = await Promise.all([
discoverDefaultRootRecords(warn, defaultRootsBase),
resolveRoot(activeRoot, true, warn),
])
const roots = new Map<string, SweepRoot>()
for (const root of discovered) roots.set(root.identity, { path: root.path, pruneWhenEmpty: true })
if (active !== undefined) roots.set(active.identity, { path: active.path, pruneWhenEmpty: false })
return [...roots.values()]
}
+101 -2
View File
@@ -3,21 +3,30 @@
* `@deepseek-ai/dsh-spill` storage seam. Persists a tool's oversized text to a
* private, session-scoped file (see `./store.ts` for the traversal-safe naming
* and exclusive owner-only write) and returns a path locator plus local
* read/grep retrieval guidance.
* read/grep retrieval guidance. After activation it runs one best-effort
* startup sweep that reclaims spill files older than `cleanupPeriodDays`.
*
* @module @deepseek-ai/dsh-spill-local
*/
import { Context } from '@deepseek-ai/cordis'
import { resolve } from 'node:path'
import { tmpdir } from 'node:os'
import z from '@deepseek-ai/schemastery'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import { gatherSweepRoots, sweepSpillRoots } from './cleanup.ts'
import type { SweepRoot, WarnFn } from './cleanup.ts'
import { privateRoot, saveTextFile } from './store.ts'
export { encodeSegment, privateRoot, saveTextFile, sessionDir } from './store.ts'
export { discoverDefaultRoots, sweepSpillRoots } from './cleanup.ts'
export type { SweepOptions, SweepRoot, WarnFn } from './cleanup.ts'
export { DEFAULT_ROOT_PREFIX, encodeSegment, isErrno, privateRoot, saveTextFile, sessionDir } from './store.ts'
export type { SavedText, SaveTextOptions } from './store.ts'
/** Milliseconds in one day — converts the `cleanupPeriodDays` config to the sweep cutoff. */
const MS_PER_DAY = 24 * 60 * 60 * 1000
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
/**
@@ -26,25 +35,115 @@ export interface Config {
* a local deployment. Set it to keep spill files under a known location.
*/
root?: string
/**
* Age in days after which a spill file is eligible for the one-shot startup
* cleanup sweep. Defaults to `30`; `0` disables cleanup entirely. Files whose
* `mtime` is strictly older than the cutoff are deleted and emptied
* directories are pruned; fresh files, symlinks, and unrelated entries are
* left untouched. On POSIX, cleanup skips roots and session directories that
* another local user could modify or replace. Retention is deliberate a
* resumed or forked session may still reference an older locator until it
* ages out.
*/
cleanupPeriodDays?: number
}
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Omit<Config, 'root'>> & Pick<Config, 'root'>
/**
* Local-filesystem spill backend. Files land under `<root>/session-<hash>/…`
* with unpredictable names, an exclusive owner-only (0600) write, and a private
* (0700) root a spilled tool result must not be readable by other local users
* or redirectable via a planted symlink.
*
* After activation it launches ONE best-effort cleanup sweep (see
* {@link cleanupPeriodDays}) that reclaims expired spill files without delaying
* service availability; the sweep is owned by the plugin fiber and awaited
* during disposal, so a fiber unload never returns before it quiesces.
*/
export class LocalSpillStore extends SpillStore {
static Config: z<Config> = z.object({
root: z.string(),
cleanupPeriodDays: z.number().step(1).min(0).default(30),
})
/** Resolved absolute spill root (config `root`, else the private default), fixed at construction. */
readonly root: string
/** Validated config (schemastery applied the `cleanupPeriodDays` default before construction). */
readonly config: ResolvedConfig
/**
* The in-flight (or settled) startup cleanup sweep. Held so disposal can await
* it; `undefined` when cleanup is disabled (`cleanupPeriodDays === 0`).
*/
private cleanup: Promise<void> | undefined
constructor(ctx: Context, config: Config) {
super(ctx)
// schemastery (static Config) has already filled `cleanupPeriodDays`; the
// cast records that runtime fact for exactOptionalPropertyTypes.
this.config = config as ResolvedConfig
this.root = config.root !== undefined ? resolve(config.root) : privateRoot()
// One best-effort startup sweep, owned by the fiber. The generator body runs
// at activation but does NOT await the sweep — it launches it and yields an
// async disposer that awaits the SAME promise, so service availability is
// never delayed yet a fiber unload reaches quiescence (no sweep I/O outlives
// the fiber). Disabled (`cleanupPeriodDays === 0`) yields a no-op disposer.
ctx.effect(function* (this: LocalSpillStore) {
if (this.config.cleanupPeriodDays > 0) {
const warn: WarnFn = (message) => { this.ctx.logger.warn(message) }
this.cleanup = this.runCleanup(warn)
}
yield async () => { await this.cleanup }
}.bind(this), 'spill-local cleanup sweep')
}
/**
* Run the one-shot cleanup: gather the roots to sweep (see {@link gatherRoots})
* and sweep all of them at the age cutoff. Best-effort
* {@link sweepSpillRoots} contains every filesystem failure, so this never
* rejects and cannot fail activation or a concurrent spill write.
*
* @param warn - sink for a contained filesystem failure.
* @returns Resolves when the sweep finishes (never rejects).
*/
private async runCleanup(warn: WarnFn): Promise<void> {
const cutoffMs = Date.now() - this.config.cleanupPeriodDays * MS_PER_DAY
const roots = await this.gatherRoots(warn)
await sweepSpillRoots({ roots, cutoffMs, warn })
}
/**
* The roots the startup sweep covers: each discovered prior-default
* `dsh-spill-*` temp root (see {@link discoverDefaultRoots}), pruned when
* emptied, plus the active/configured root, which is never itself pruned while
* the live process may write into it. Empty session directories are pruned in
* every root. Filesystem identity de-duplicates aliases before the active root
* overrides a discovered match as non-prunable. A test overrides this to
* inject an isolated root set and, being the sweep's one async gather point,
* to hold the sweep open across a disposal for the quiescence check; it is a
* test seam, not a deployment knob.
*
* @param warn - sink for a contained discovery failure.
* @returns The roots to sweep, each flagged for prune-when-empty.
*/
protected async gatherRoots(warn: WarnFn): Promise<SweepRoot[]> {
return gatherSweepRoots(this.root, warn, this.defaultRootsBase())
}
/**
* The directory scanned for prior default `dsh-spill-*` roots the OS tmpdir,
* where {@link privateRoot} creates them (accumulation only happens there). A
* test overrides this to point discovery at an isolated fixture instead of the
* real tmpdir; it is a test seam, not a deployment knob.
*
* @returns The base directory to scan for default spill roots.
*/
protected defaultRootsBase(): string {
return tmpdir()
}
async saveText(input: SaveTextSpill): Promise<SpillRef> {
+52 -37
View File
@@ -12,18 +12,29 @@ import { mkdir, open } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
/** Prefix shared by default-root creation and startup discovery. */
export const DEFAULT_ROOT_PREFIX = 'dsh-spill-'
/**
* Test a caught value for a Node system error code.
*
* @param error The caught value.
* @param code The expected system error code.
* @returns Whether the code matches.
*/
export function isErrno(error: unknown, code: string): boolean {
return error instanceof Error && (error as NodeJS.ErrnoException).code === code
}
let defaultRoot: string | undefined
/**
* The default spill root: a private (0700) per-process directory under the OS
* tmpdir, created lazily. Predictable world-readable paths would let other
* local users read spilled tool output or pre-create symlinks; `mkdtemp` gives
* an unpredictable suffix and 0700 semantics.
* Return the lazily-created private per-process spill root.
*
* @returns The lazily-created private spill root.
* @returns The private root path.
*/
export function privateRoot(): string {
defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-'))
defaultRoot ??= mkdtempSync(join(tmpdir(), DEFAULT_ROOT_PREFIX))
return defaultRoot
}
@@ -38,8 +49,8 @@ export function privateRoot(): string {
* inputs never collide. The whole-segment tokens `.`/`..` are escaped so they
* can never traverse. An empty string encodes to `~` (never an empty segment).
*
* @param raw The untrusted string to encode as one safe path segment.
* @returns An injective, filesystem-safe single path segment.
* @param raw Untrusted text.
* @returns One injective filesystem-safe path segment.
*/
export function encodeSegment(raw: string): string {
if (raw.length === 0) return '~'
@@ -49,68 +60,72 @@ export function encodeSegment(raw: string): string {
for (let i = 0; i < raw.length; i++) {
const code = raw.charCodeAt(i)
const ch = String.fromCharCode(code)
if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
out += ch
} else {
out += '~' + code.toString(16).toUpperCase().padStart(4, '0')
}
out += ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)
? ch
: '~' + code.toString(16).toUpperCase().padStart(4, '0')
}
return out
}
/* jscpd:ignore-end */
/**
* The session-scoped directory: `<root>/session-<hash(sessionId)>`, a short stable hash.
* Derive the stable session-scoped directory under a spill root.
*
* @param root The spill root directory.
* @param sessionId The owning session id to hash into a stable directory name.
* @returns The absolute session-scoped spill directory path.
* @param root The spill root.
* @param sessionId The owning session id.
* @returns The stable session-scoped directory.
*/
export function sessionDir(root: string, sessionId: string): string {
const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 12)
return join(root, `session-${hash}`)
}
/** Options for {@link saveTextFile} — the resolved root and the request fields the store needs. */
/** Inputs needed to save a local spill file. */
export interface SaveTextOptions {
/** The spill root directory (configured or the lazy private default). */
/** Spill root. */
root: string
/** The owning session id (scopes the directory). */
/** Owning session id. */
sessionId: string
/** Caller-suggested base name; sanitized to one safe segment before use. */
/** Caller-suggested filename. */
suggestedName: string
/** The full text to persist. */
/** Full text to persist. */
content: string
}
/** A written spill file. */
export interface SavedText {
/** Absolute saved path. */
path: string
/** UTF-8 content length. */
bytes: number
}
/**
* Write `content` to a fresh file under the session-scoped directory and return
* its path + byte length. The filename is a random hex prefix plus the
* sanitized `suggestedName`, so it is unpredictable (defeats symlink planting in
* a shared root) AND stays readable. The open is exclusive + owner-only
* (`'wx', 0o600`): it fails on any existing path symlink or not so a
* pre-planted target cannot redirect the write.
*
* @param options The resolved root and request fields required to save the file.
* @returns The written file path and UTF-8 byte length.
* Write text to a fresh 0600 file below its private session directory.
* @param options The save request.
* @returns The saved path and UTF-8 byte length.
*/
export async function saveTextFile(options: SaveTextOptions): Promise<SavedText> {
const dir = sessionDir(options.root, options.sessionId)
await mkdir(dir, { recursive: true, mode: 0o700 })
const safeName = encodeSegment(options.suggestedName)
const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`)
const bytes = Buffer.byteLength(options.content, 'utf8')
const handle = await open(path, 'wx', 0o600)
const path = join(dir, `${randomBytes(6).toString('hex')}-${encodeSegment(options.suggestedName)}`)
let handle
for (;;) {
await mkdir(dir, { recursive: true, mode: 0o700 })
try {
handle = await open(path, 'wx', 0o600)
break
} catch (error: unknown) {
/* v8 ignore start -- requires another process to remove the directory
between mkdir and open, or an external permission/IO race. */
if (isErrno(error, 'ENOENT')) continue
throw error
/* v8 ignore stop */
}
}
try {
await handle.writeFile(options.content)
} finally {
await handle.close()
}
return { path, bytes }
return { path, bytes: Buffer.byteLength(options.content, 'utf8') }
}
@@ -0,0 +1,78 @@
/**
* Real-composition proof: a cordis.yml loaded by the vendored Loader applies
* spill-local configuration and completes its fiber-owned startup cleanup.
*/
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import LocalSpillStore, { sessionDir } from '@deepseek-ai/dsh-spill-local'
const DAY_MS = 24 * 60 * 60 * 1000
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('spill-local real Loader composition through cordis.yml', () => {
it('loads cleanupPeriodDays and prunes only expired session contents', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-spill-loader-'))
const oldDir = sessionDir(root, 'old-session')
const freshDir = sessionDir(root, 'fresh-session')
await mkdir(oldDir, { recursive: true })
await mkdir(freshDir, { recursive: true })
const old = join(oldDir, 'old.txt')
const fresh = join(freshDir, 'fresh.txt')
await writeFile(old, 'old')
await writeFile(fresh, 'fresh')
const now = Date.now()
await utimes(old, (now - 40 * DAY_MS) / 1000, (now - 40 * DAY_MS) / 1000)
await utimes(fresh, (now - DAY_MS) / 1000, (now - DAY_MS) / 1000)
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-spill-local'",
' config:',
` root: ${JSON.stringify(root)}`,
' cleanupPeriodDays: 30',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier !== '@deepseek-ai/dsh-spill-local') throw new Error(`unexpected Loader import: ${specifier}`)
return LocalSpillStore
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
await context.fiber.dispose()
context = undefined
expect(existsSync(old)).toBe(false)
expect(existsSync(oldDir)).toBe(false)
expect(existsSync(fresh)).toBe(true)
expect(existsSync(freshDir)).toBe(true)
expect(existsSync(root)).toBe(true)
}, 30_000)
})
@@ -2,19 +2,36 @@
* Tests for the LOCAL spill backend: `saveText` writes a session-scoped file and
* returns a locator + byte length + retrieval hint, filename sanitization
* neutralizes traversal, the configured `root` is honored (and the private
* default when omitted), and a storage failure rejects. The Cordis-free
* `store.ts` helpers are exercised directly for the naming/encoding edge cases.
* default when omitted), and a storage failure rejects. The startup cleanup
* sweep expires old files, prunes stale roots, skips symlinks/unknown entries,
* discovers prior default roots, contains filesystem failures, and is awaited on
* disposal without blocking activation. The Cordis-free store and cleanup
* helpers are exercised directly for their edge cases.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
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'
import LocalSpillStore, { encodeSegment, privateRoot, saveTextFile, sessionDir } from '@deepseek-ai/dsh-spill-local'
import LocalSpillStore, {
DEFAULT_ROOT_PREFIX,
discoverDefaultRoots,
encodeSegment,
isErrno,
privateRoot,
saveTextFile,
sessionDir,
sweepSpillRoots,
} from '@deepseek-ai/dsh-spill-local'
import type { SweepRoot } from '@deepseek-ai/dsh-spill-local'
import { gatherSweepRoots } from '../src/cleanup.ts'
const DAY_MS = 24 * 60 * 60 * 1000
let root: string
@@ -25,6 +42,13 @@ afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
/** Write a file with an mtime `ageDays` in the past (fractional allowed). */
function writeAged(path: string, content: string, ageDays: number): void {
writeFileSync(path, content)
const when = (Date.now() - ageDays * DAY_MS) / 1000
utimesSync(path, when, when)
}
function request(overrides: Partial<SaveTextSpill> = {}): SaveTextSpill {
return {
owner: { sessionId: SessionId('sess-1') },
@@ -113,9 +137,11 @@ describe('privateRoot', () => {
})
describe('LocalSpillStore service', () => {
// These tests exercise save/root resolution, not cleanup; disabling the sweep
// (cleanupPeriodDays: 0) keeps them from scanning/sweeping the real tmpdir.
it('registers as ctx.spillStore and saves under the configured root', async () => {
const ctx = new Context()
await ctx.plugin(LocalSpillStore, { root })
await ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 })
const ref = await ctx.spillStore.saveText(request())
expect(dirname(ref.locator)).toBe(sessionDir(root, 'sess-1'))
expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
@@ -125,13 +151,13 @@ describe('LocalSpillStore service', () => {
it('resolves a relative configured root to absolute', async () => {
const ctx = new Context()
await ctx.plugin(LocalSpillStore, { root: '.' })
await ctx.plugin(LocalSpillStore, { root: '.', cleanupPeriodDays: 0 })
expect(isAbsolute((ctx.spillStore as LocalSpillStore).root)).toBe(true)
})
it('falls back to the private root when none is configured', async () => {
const ctx = new Context()
await ctx.plugin(LocalSpillStore, {})
await ctx.plugin(LocalSpillStore, { cleanupPeriodDays: 0 })
expect((ctx.spillStore as LocalSpillStore).root).toBe(privateRoot())
})
@@ -139,7 +165,418 @@ describe('LocalSpillStore service', () => {
const ctx = new Context()
// A file (not a dir) as the root makes mkdir under it fail — a real storage error.
const filePath = (await saveTextFile({ root, sessionId: 's', suggestedName: 'f', content: 'x' })).path
await ctx.plugin(LocalSpillStore, { root: filePath })
await ctx.plugin(LocalSpillStore, { root: filePath, cleanupPeriodDays: 0 })
await expect(ctx.spillStore.saveText(request())).rejects.toThrow()
})
it('rejects a negative or fractional cleanupPeriodDays at load', async () => {
await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: -1 }))
.rejects.toThrow()
await expect(new Context().plugin(LocalSpillStore, { root, cleanupPeriodDays: 1.5 }))
.rejects.toThrow()
})
it('defaults cleanupPeriodDays to 30', async () => {
const ctx = new Context()
// Point discovery at an empty isolated base so the default sweep does not
// touch the real tmpdir; assert only that the default landed on config.
const emptyBase = mkdtempSync(join(tmpdir(), 'dsh-empty-'))
class Isolated extends LocalSpillStore {
protected override defaultRootsBase(): string { return emptyBase }
}
try {
const fiber = await ctx.plugin(Isolated, { root })
const store = ctx.spillStore as LocalSpillStore
await fiber.dispose()
expect(store.config.cleanupPeriodDays).toBe(30)
} finally {
rmSync(emptyBase, { recursive: true, force: true })
}
})
it('the default discovery base is the OS tmpdir', async () => {
// Every hermetic sweep test overrides defaultRootsBase(); pin its production
// default here (scan the OS tmpdir) without letting the sweep touch tmpdir.
class Exposed extends LocalSpillStore {
base(): string { return this.defaultRootsBase() }
protected override async gatherRoots(): Promise<SweepRoot[]> { return [] }
}
const ctx = new Context()
const fiber = await ctx.plugin(Exposed, { root, cleanupPeriodDays: 30 })
const store = ctx.spillStore as Exposed
await fiber.dispose()
expect(store.base()).toBe(tmpdir())
})
it('routes a sweep filesystem failure to ctx.logger.warn (service warn wiring)', async () => {
// A root that is a FILE, not a directory, is rejected by the real sweep.
// The service's warn closure must forward that failure to
// ctx.logger.warn, and disposal must still settle cleanly.
const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x')
const ctx = new Context()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
class Discovering extends LocalSpillStore {
protected override async gatherRoots(): Promise<SweepRoot[]> { return [{ path: this.root, pruneWhenEmpty: false }] }
}
const fiber = await ctx.plugin(Discovering, { root: filePath, cleanupPeriodDays: 30 })
await fiber.dispose()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe root'))
})
})
/**
* A store whose sweep covers exactly the roots handed in (no real-tmpdir scan)
* the hermetic seam for the cleanup tests. `barrier`, when set, holds the async
* gather open so a test can prove disposal awaits the sweep.
*/
class SweptStore extends LocalSpillStore {
static sweepRoots: SweepRoot[] = []
static barrier: Promise<void> | undefined
protected override async gatherRoots(): Promise<SweepRoot[]> {
if (SweptStore.barrier) await SweptStore.barrier
return SweptStore.sweepRoots
}
}
/** Sweep the given roots via the fiber-owned startup sweep; `root` is the active (non-pruned) root. */
async function runSweep(roots: SweepRoot[], cleanupPeriodDays = 30): Promise<void> {
SweptStore.sweepRoots = roots
SweptStore.barrier = undefined
const ctx = new Context()
const fiber = await ctx.plugin(SweptStore, { root, cleanupPeriodDays })
// Disposal awaits the fiber-owned sweep, so after this the sweep has run.
await fiber.dispose()
}
/** The active configured root as a non-pruned sweep target (the common single-root case). */
function active(path: string): SweepRoot {
return { path, pruneWhenEmpty: false }
}
describe('startup cleanup sweep', () => {
it('deletes files older than the cutoff and keeps fresh ones', async () => {
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
const fresh = join(dir, 'fresh.txt'); writeAged(fresh, 'y', 1)
await runSweep([active(root)])
expect(existsSync(old)).toBe(false)
expect(existsSync(fresh)).toBe(true)
})
it('keeps a file exactly at the boundary (only strictly-older expires)', async () => {
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
const cutoffMs = Date.now() - 30 * DAY_MS
const boundary = join(dir, 'boundary.txt')
writeFileSync(boundary, 'x')
utimesSync(boundary, cutoffMs / 1000, cutoffMs / 1000)
await sweepSpillRoots({ roots: [active(root)], cutoffMs, warn: () => {} })
expect(existsSync(boundary)).toBe(true)
})
it('disabled (cleanupPeriodDays: 0) sweeps nothing', async () => {
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 400)
await runSweep([active(root)], 0)
expect(existsSync(old)).toBe(true)
})
it('prunes empty active session directories after deleting expired files', async () => {
const emptied = sessionDir(root, 'emptied')
const kept = sessionDir(root, 'kept')
mkdirSync(emptied, { recursive: true })
mkdirSync(kept, { recursive: true })
writeAged(join(emptied, 'a.txt'), 'x', 40)
writeAged(join(kept, 'fresh.txt'), 'y', 1)
await runSweep([active(root)])
expect(existsSync(emptied)).toBe(false)
expect(existsSync(kept)).toBe(true)
})
it('skips a symlink INSIDE a session dir and non-session siblings', async () => {
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
// A symlink pointing at an old target must NOT be followed or deleted.
const target = join(root, 'target.txt'); writeAged(target, 'keep', 40)
const link = join(dir, 'link.txt'); symlinkSync(target, link)
// A non-session sibling directory under a shared root is untouched.
const unrelated = join(root, 'not-a-session'); mkdirSync(unrelated)
const unrelatedOld = join(unrelated, 'old.txt'); writeAged(unrelatedOld, 'x', 40)
await runSweep([active(root)])
// The symlink itself survives (lstat sees a link, not a file), so its dir is
// not empty and is not pruned; the link target survives too.
expect(existsSync(link)).toBe(true)
expect(existsSync(target)).toBe(true)
expect(existsSync(unrelatedOld)).toBe(true)
})
it('does NOT follow a symlinked session directory (no deletion in the target)', async () => {
// A `session-<12hex>`-NAMED symlink pointing at a directory of old files must
// never be descended: lstat on the entry sees a link, so the target's files
// are left intact and the link itself is not removed.
const victimDir = join(root, 'victim'); mkdirSync(victimDir, { recursive: true })
const victimOld = join(victimDir, 'old.txt'); writeAged(victimOld, 'x', 40)
const linkName = `session-${'a'.repeat(12)}`
const link = join(root, linkName); symlinkSync(victimDir, link)
await runSweep([active(root)])
expect(existsSync(victimOld)).toBe(true)
expect(existsSync(link)).toBe(true)
})
it('skips a POSIX session directory writable by another local user', async () => {
if (process.platform === 'win32') return
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
chmodSync(dir, 0o777)
const warn = vi.fn()
await sweepSpillRoots({ roots: [active(root)], cutoffMs: Date.now(), warn })
expect(existsSync(old)).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe session directory'))
})
it('sweeps only exact session-<12hex> names, not lookalikes', async () => {
// `session-backup` and `session-<11hex>` match the old startsWith check but
// are NOT backend-generated names; their old files must survive.
const backup = join(root, 'session-backup'); mkdirSync(backup, { recursive: true })
const backupOld = join(backup, 'old.txt'); writeAged(backupOld, 'x', 40)
const shortHex = join(root, `session-${'a'.repeat(11)}`); mkdirSync(shortHex, { recursive: true })
const shortOld = join(shortHex, 'old.txt'); writeAged(shortOld, 'x', 40)
// A real session dir alongside them IS swept, proving the sweep still runs.
const real = sessionDir(root, 'sess-1'); mkdirSync(real, { recursive: true })
const realOld = join(real, 'old.txt'); writeAged(realOld, 'x', 40)
await runSweep([active(root)])
expect(existsSync(backupOld)).toBe(true)
expect(existsSync(shortOld)).toBe(true)
expect(existsSync(realOld)).toBe(false)
})
it('prunes an emptied DISCOVERED default root but never the active root', async () => {
// A discovered prior-default root (pruneWhenEmpty) whose only session dir is
// emptied should have its outer directory removed too; the active root, even
// when fully emptied, must survive (the live process still writes into it).
const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-'))
const priorDir = sessionDir(prior, 'old-sess'); mkdirSync(priorDir, { recursive: true })
writeAged(join(priorDir, 'old.txt'), 'x', 40)
const activeDir = sessionDir(root, 'sess-1'); mkdirSync(activeDir, { recursive: true })
writeAged(join(activeDir, 'old.txt'), 'x', 40)
try {
await runSweep([{ path: prior, pruneWhenEmpty: true }, active(root)])
expect(existsSync(prior)).toBe(false) // discovered root pruned
expect(existsSync(root)).toBe(true) // active root kept
expect(existsSync(activeDir)).toBe(false) // empty active session dirs are pruned
} finally {
rmSync(prior, { recursive: true, force: true })
}
})
it('de-duplicates repeated roots and lets non-prunable status win', async () => {
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
writeAged(join(dir, 'old.txt'), 'x', 40)
await sweepSpillRoots({
roots: [
{ path: root, pruneWhenEmpty: true },
{ path: root, pruneWhenEmpty: false },
{ path: root, pruneWhenEmpty: true },
],
cutoffMs: Date.now() - 30 * DAY_MS,
warn: () => {},
})
expect(existsSync(dir)).toBe(false)
expect(existsSync(root)).toBe(true)
})
it('does NOT prune a discovered root that still holds a fresh file', async () => {
const prior = mkdtempSync(join(tmpdir(), 'dsh-spill-'))
const priorDir = sessionDir(prior, 'sess'); mkdirSync(priorDir, { recursive: true })
writeAged(join(priorDir, 'fresh.txt'), 'y', 1)
try {
await runSweep([{ path: prior, pruneWhenEmpty: true }])
expect(existsSync(prior)).toBe(true)
expect(existsSync(priorDir)).toBe(true)
} finally {
rmSync(prior, { recursive: true, force: true })
}
})
it('covers the configured root AND discovered default roots (real gatherRoots)', async () => {
// A prior default root under an isolated fake tmpdir + the configured root.
// This test drives the REAL gatherRoots/discoverDefaultRoots path by seaming
// only the tmpdir scan base, not gatherRoots itself.
const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
const priorDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX))
const priorDir = sessionDir(priorDefault, 'old-sess')
mkdirSync(priorDir, { recursive: true })
const priorOld = join(priorDir, 'old.txt'); writeAged(priorOld, 'x', 40)
const cfgDir = sessionDir(root, 'sess-1')
mkdirSync(cfgDir, { recursive: true })
const cfgOld = join(cfgDir, 'old.txt'); writeAged(cfgOld, 'x', 40)
class Discovering extends LocalSpillStore {
protected override defaultRootsBase(): string { return fakeTmp }
}
try {
const ctx = new Context()
const fiber = await ctx.plugin(Discovering, { root, cleanupPeriodDays: 30 })
await fiber.dispose()
expect(existsSync(priorOld)).toBe(false)
expect(existsSync(cfgOld)).toBe(false)
// The discovered prior-default root is pruned; the configured root is kept.
expect(existsSync(priorDefault)).toBe(false)
expect(existsSync(root)).toBe(true)
} finally {
rmSync(fakeTmp, { recursive: true, force: true })
}
})
it('de-dups when the active root is itself a discovered default (real gatherRoots)', async () => {
// The configured root lives directly under the seamed base and matches the
// default shape, so discovery finds it AND it is the active root — the sweep
// must run once, not choke on the duplicate, and must NOT prune the active
// root even though discovery would otherwise mark a default root prunable.
const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
const activeDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX))
const dir = sessionDir(activeDefault, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
class Discovering extends LocalSpillStore {
protected override defaultRootsBase(): string { return fakeTmp }
}
try {
const ctx = new Context()
const fiber = await ctx.plugin(Discovering, { root: activeDefault, cleanupPeriodDays: 30 })
await fiber.dispose()
expect(existsSync(old)).toBe(false)
// Active root survives even though its name matches the discovered shape.
expect(existsSync(activeDefault)).toBe(true)
} finally {
rmSync(fakeTmp, { recursive: true, force: true })
}
})
it('de-dups a configured symlink alias by filesystem identity and keeps its target writable', async () => {
const fakeTmp = mkdtempSync(join(tmpdir(), 'dsh-faketmp-'))
const activeDefault = mkdtempSync(join(fakeTmp, DEFAULT_ROOT_PREFIX))
const alias = join(root, 'configured-root')
symlinkSync(activeDefault, alias, process.platform === 'win32' ? 'junction' : 'dir')
const dir = sessionDir(activeDefault, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
try {
const roots = await gatherSweepRoots(alias, () => {}, fakeTmp)
expect(roots).toEqual([{ path: await realpath(activeDefault), pruneWhenEmpty: false }])
await sweepSpillRoots({ roots, cutoffMs: Date.now() - 30 * DAY_MS, warn: () => {} })
expect(existsSync(old)).toBe(false)
expect(existsSync(activeDefault)).toBe(true)
const saved = await saveTextFile({ root: alias, sessionId: 'next', suggestedName: 'ok.txt', content: 'ok' })
expect(readFileSync(saved.path, 'utf8')).toBe('ok')
} finally {
rmSync(fakeTmp, { recursive: true, force: true })
}
})
it('omits a missing active root', async () => {
expect(await gatherSweepRoots(join(root, 'missing'), () => {}, root)).toEqual([])
})
it('skips a root that another POSIX user could replace', async () => {
if (process.platform === 'win32') return
const unsafeParent = join(root, 'unsafe-parent')
const unsafeRoot = join(unsafeParent, 'configured')
mkdirSync(unsafeRoot, { recursive: true, mode: 0o700 })
const dir = sessionDir(unsafeRoot, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
chmodSync(unsafeParent, 0o777)
const warn = vi.fn()
const roots = await gatherSweepRoots(unsafeRoot, warn, join(root, 'missing-discovery-base'))
expect(roots).toEqual([])
expect(existsSync(old)).toBe(true)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe root'))
})
it('does not block activation but is awaited on disposal (quiescence)', async () => {
const dir = sessionDir(root, 'sess-1')
mkdirSync(dir, { recursive: true })
const old = join(dir, 'old.txt'); writeAged(old, 'x', 40)
// Hold the sweep open behind a barrier we control.
let release!: () => void
SweptStore.sweepRoots = [active(root)]
SweptStore.barrier = new Promise<void>((resolve) => { release = resolve })
const ctx = new Context()
const fiber = await ctx.plugin(SweptStore, { root, cleanupPeriodDays: 30 })
// Activation returned while the sweep is still parked: service is usable and
// the old file is untouched so far.
expect(existsSync(old)).toBe(true)
const ref = await ctx.spillStore.saveText(request())
expect(readFileSync(ref.locator, 'utf8')).toBe('the full body')
// Disposal must AWAIT the sweep: release the barrier, and dispose only
// settles after the sweep deleted the old file.
release()
await fiber.dispose()
expect(existsSync(old)).toBe(false)
})
it('an unsafe root is contained (logged, never thrown)', async () => {
const warn = vi.fn()
// A path that is a FILE, not a directory, is not a valid cleanup root. The
// sweep must log and return, never reject.
const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x')
await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipped unsafe root'))
})
it('contains an exception from the warning sink', async () => {
const filePath = join(root, 'not-a-dir'); writeFileSync(filePath, 'x')
const warn = vi.fn(() => { throw new Error('logger failed') })
await expect(sweepSpillRoots({ roots: [active(filePath)], cutoffMs: Date.now(), warn })).resolves.toBeUndefined()
expect(warn).toHaveBeenCalledOnce()
})
it('a nonexistent root is silent (the common no-spill-yet case)', async () => {
const warn = vi.fn()
await sweepSpillRoots({ roots: [active(join(root, 'never-created'))], cutoffMs: Date.now(), warn })
expect(warn).not.toHaveBeenCalled()
})
})
describe('discoverDefaultRoots', () => {
it('returns only real dsh-spill-* directories, excluding symlinks and non-matches', async () => {
const base = mkdtempSync(join(tmpdir(), 'dsh-disc-'))
try {
// A real backend-shaped root (dsh-spill-<6>) via mkdtemp — the only match.
const realRoot = mkdtempSync(join(base, DEFAULT_ROOT_PREFIX))
mkdirSync(join(base, 'unrelated-dir'))
// Names of the EXACT default shape that must still be excluded because they
// are not real directories the backend could have created.
writeFileSync(join(base, `${DEFAULT_ROOT_PREFIX}file01`), 'x') // matches shape but is a file
symlinkSync(realRoot, join(base, `${DEFAULT_ROOT_PREFIX}link01`)) // matches shape but is a symlink
const found = await discoverDefaultRoots(() => {}, base)
expect(found).toEqual([await realpath(realRoot)])
} finally {
rmSync(base, { recursive: true, force: true })
}
})
it('returns [] and warns when the base is unreadable', async () => {
const warn = vi.fn()
const missing = join(root, 'no-such-base')
expect(await discoverDefaultRoots(warn, missing)).toEqual([])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to scan'))
})
})
describe('isErrno', () => {
it('matches a Node system error by code and rejects non-matches', () => {
const err = Object.assign(new Error('boom'), { code: 'ENOENT' })
expect(isErrno(err, 'ENOENT')).toBe(true)
expect(isErrno(err, 'EPERM')).toBe(false)
expect(isErrno('not an error', 'ENOENT')).toBe(false)
expect(isErrno(new Error('no code'), 'ENOENT')).toBe(false)
})
})
+1 -1
View File
@@ -211,7 +211,7 @@ export function apply(ctx: Context, config: Config): void {
// lives in tool plugins as prompt sections, not in the deployment persona).
ctx.systemPrompt.section({
name: `tool:${toolName}`,
order: 115,
order: 115.5,
text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`,
})
ctx.tools.register(defineTool({
+6
View File
@@ -8053,6 +8053,12 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/cordis-plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand