Merge worktree/schedule-explicit-at into worktree/schedule-fixed-rate
This commit is contained in:
@@ -2,17 +2,17 @@
|
||||
|
||||
These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions).
|
||||
|
||||
- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- **Plugin exports:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external services or nondeterministic inputs and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
|
||||
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement point; otherwise fold it while preserving rollback, callback containment, and quiescence.
|
||||
- **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
|
||||
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
|
||||
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
|
||||
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
|
||||
- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
|
||||
- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
|
||||
- **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
|
||||
- **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
|
||||
- **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits.
|
||||
- **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal.
|
||||
- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md).
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 51f926f02bd4df0ada61bda88b4181fe3ebea89f
|
||||
README.zh.md: c9912481febcbc742e2853a1c8eff822c7810245
|
||||
README.md: 7a1fce6e361a47cf4ac6f02a76107e049411662e
|
||||
README.zh.md: 9ea5953299874e7f27b8a2fedb8c06790e83065a
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
|
||||
npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions).
|
||||
|
||||
## Hierarchy
|
||||
|
||||
@@ -10,48 +10,49 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
|
||||
| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | Product — stable surface |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable API |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable API |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable API |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable API |
|
||||
| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | Product — stable API |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable API |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API |
|
||||
| [`e2b/`](e2b/README.md) | E2B providers | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable surface |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable API |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable API |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable API |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable API |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable API |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable API |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable API |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable API |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable API |
|
||||
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable API |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable API |
|
||||
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable API |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable API |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable API |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable API |
|
||||
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable API |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable API |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable API |
|
||||
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable API |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable API |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable API |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
|
||||
| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable API |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable API |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
|
||||
|
||||
## 层级结构
|
||||
|
||||
@@ -32,12 +32,14 @@
|
||||
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 |
|
||||
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
|
||||
| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
|
||||
| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 |
|
||||
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
|
||||
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
|
||||
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 |
|
||||
| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查和模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定接口 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 |
|
||||
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |
|
||||
@@ -45,13 +47,12 @@
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
|
||||
| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
|
||||
| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定接口 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 |
|
||||
| [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
|
||||
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 |
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/acp/acp"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -26,14 +33,14 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"schemastery": "^3.17.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
@@ -44,6 +51,6 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
* @module @deepseek-ai/dsh-acp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import Schema from 'schemastery'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
@@ -166,6 +166,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
content: { type: 'text', text: block.text },
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'image') {
|
||||
notify({
|
||||
sessionId: record.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `[image attachment ${block.attachment.attachmentId}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,6 +252,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
assertOpen()
|
||||
validateSessionParams(params)
|
||||
const sessionId = SessionId(randomUUID())
|
||||
// No preset composition: the ACP bundle keeps the model-facing rows in
|
||||
// the host plane, so this agent reads them from the global layer. A
|
||||
// deployment that configures a roster has to join one here first
|
||||
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-acp'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** In-memory ACP transport fixture over the real agent factory and loop. */
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
|
||||
@@ -41,6 +41,35 @@ describe('ACP prompt lifecycle', () => {
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
|
||||
})
|
||||
|
||||
it('renders an assistant image as an explicit attachment placeholder', async () => {
|
||||
const attachmentId = `sha256:${'a'.repeat(64)}` as never
|
||||
harness = await makeBridgeHarness({
|
||||
script: [[
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]],
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
|
||||
await vi.waitFor(() => {
|
||||
expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a failed turn and never publishes its partial chunks', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "TypeRT Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/api/gateway"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -26,13 +33,15 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-typert-registry",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
@@ -50,17 +59,17 @@
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* participates in method lookup, invocation, or type exposure.
|
||||
*/
|
||||
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
InvocationDescriptor,
|
||||
@@ -53,7 +53,7 @@ interface RemoteNamespaceHandle {
|
||||
/** Typed Remote service augmented by generated direct namespaces. */
|
||||
export type ClientRemote = TypeRTClientRemote
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by the Client assembly. */
|
||||
remote: ClientRemote
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-api-gateway
|
||||
*/
|
||||
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection'
|
||||
import {
|
||||
remoteMethods,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway'
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface TypertGateway {
|
||||
invoke(request: InvokeRemoteRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host dispatcher for TypeRT Remote calls. */
|
||||
typertGateway: TypertGateway
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import { Context, Service, symbols } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.md: 567ece0fb58d4b9c0b022dd2ce4d8ee87caccc57
|
||||
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a
|
||||
|
||||
@@ -20,7 +20,7 @@ The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bun
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
None, as this BFF selects Remote application methods and identity policy but registers nothing model-facing.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/api/remotes"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -22,12 +29,14 @@
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway"
|
||||
],
|
||||
"platform": "web",
|
||||
"immediately": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
@@ -44,13 +53,13 @@
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-typert-registry": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
@@ -59,6 +68,6 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Host BFF policy for resolving Remote Agent and Session identities. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -22,8 +22,20 @@ export type ApiRemoteAgentResult =
|
||||
export interface ApiRemoteAgentOptions {
|
||||
/** Read the per-Agent defaults when a cold identity must resume. */
|
||||
readonly agentOptions?: () => AgentOptions
|
||||
/** Host-specific Agent-scope composition completed before publication. */
|
||||
readonly setup?: AgentSetup
|
||||
/**
|
||||
* Build the Host-specific Agent-scope composition completed before
|
||||
* publication. Keyed by the resumed session itself because what a Host
|
||||
* installs may depend on what that session recorded: an agent preset fixes
|
||||
* the tools its history was produced under, so rebuilding it under another
|
||||
* composition would replay tool calls the agent can no longer make. The
|
||||
* events come along because a session's own record of such a choice may be
|
||||
* an event rather than a header field.
|
||||
* @param session - the resumed session's persisted header and event log.
|
||||
* @returns the Agent-scope setup to run before publication.
|
||||
*/
|
||||
readonly setup?: (
|
||||
session: { meta: SessionHeader; events: readonly SessionEvent[] },
|
||||
) => AgentSetup | Promise<AgentSetup>
|
||||
}
|
||||
|
||||
/** Cold identity absent from the durable session store. */
|
||||
@@ -136,6 +148,11 @@ export function createApiRemoteAgentResolver(
|
||||
if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) {
|
||||
throw new ApiRemoteSubagentSessionOwnership(sessionId)
|
||||
}
|
||||
// Built from the inspected session before the published re-checks
|
||||
// below, so those stay adjacent to `resume` and a Host setup that
|
||||
// awaits (composing a preset, say) does not widen the collision
|
||||
// window.
|
||||
const setup = options.setup === undefined ? undefined : await options.setup(inspected)
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
const publishedAgent = ctx.agents.get(sessionId)
|
||||
if (publishedSession !== undefined
|
||||
@@ -145,7 +162,7 @@ export function createApiRemoteAgentResolver(
|
||||
const handle = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() },
|
||||
...options.setup === undefined ? {} : { setup: options.setup },
|
||||
...setup === undefined ? {} : { setup },
|
||||
})
|
||||
return handle.agent
|
||||
} finally {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/** Platform-neutral assembly of generated Host Remote contributions. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Generated Remote namespaces selected by this Client assembly. */
|
||||
remote: TypeRTClientRemote
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
|
||||
@@ -45,7 +45,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
}).map(([key, path]) => [key, artifactUrl(path)]))
|
||||
const script = `
|
||||
import { createServer } from 'node:http'
|
||||
import * as cordis from 'cordis'
|
||||
import * as cordis from '@deepseek-ai/cordis'
|
||||
|
||||
const urls = ${JSON.stringify(urls)}
|
||||
const { Context } = cordis
|
||||
@@ -123,7 +123,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => {
|
||||
const handoff = handoffs.get(id)
|
||||
if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id)
|
||||
return handoff.factory(specifier => {
|
||||
if (specifier === 'cordis') return cordis
|
||||
if (specifier === '@deepseek-ai/cordis') return cordis
|
||||
throw new Error('unexpected Client external ' + specifier)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/README.md
|
||||
README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1
|
||||
README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/README.md
|
||||
README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8
|
||||
README.zh.md: b4a80917e166a3b0836e6e9c79d536d9dd8e1a41
|
||||
12
packages/attachment/README.md
Normal file
12
packages/attachment/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# attachment/ - durable attachment capability family
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The durable binary attachment seam and its local filesystem implementation. Both are product packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` |
|
||||
| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) |
|
||||
|
||||
Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output.
|
||||
12
packages/attachment/README.zh.md
Normal file
12
packages/attachment/README.zh.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# attachment/:持久附件能力族
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
持久二进制附件服务边界及其本地文件系统实现。两者均为产品包(package)。
|
||||
|
||||
| 包 | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `attachment/` | 不可变附件引用、图片限制和存储服务 | `ctx.attachments` |
|
||||
| `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments`) |
|
||||
|
||||
未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。
|
||||
6
packages/attachment/attachment-local/README.i18n.yaml
Normal file
6
packages/attachment/attachment-local/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
|
||||
README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
|
||||
README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3
|
||||
21
packages/attachment/attachment-local/README.md
Normal file
21
packages/attachment/attachment-local/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-attachment-local
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
|
||||
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through durable replay of historical user images and structured model image output after restart and fork.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None beyond the image block owned by the requesting adapter.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Objects are retained indefinitely; reference-aware garbage collection is deferred.
|
||||
- The local backend assumes the host and provider adapter share this filesystem service.
|
||||
- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned.
|
||||
21
packages/attachment/attachment-local/README.zh.md
Normal file
21
packages/attachment/attachment-local/README.zh.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-attachment-local
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
除发起请求的适配器所持有的图片块外,不产生其他影响。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
- 对象会无限期保留;基于引用的垃圾回收尚未实现。
|
||||
- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。
|
||||
- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。
|
||||
40
packages/attachment/attachment-local/package.json
Normal file
40
packages/attachment/attachment-local/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/attachment/attachment-local"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" },
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
66
packages/attachment/attachment-local/src/image.ts
Normal file
66
packages/attachment/attachment-local/src/image.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/** Raster inspection: full decode at admission, header-only probe on verified reads. */
|
||||
|
||||
import sharp, { type Sharp } from 'sharp'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
|
||||
/** Decoded metadata from a supported image. */
|
||||
export interface DetectedImage {
|
||||
mediaType: ImageMediaType
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
}
|
||||
|
||||
async function imageMetadata(image: Sharp): Promise<DetectedImage> {
|
||||
const metadata = await image.metadata()
|
||||
const mediaType = MEDIA_TYPES[metadata.format as string]
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
return { mediaType, width: metadata.width, height: metadata.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a supported raster's header and return its intrinsic metadata without
|
||||
* decoding pixels. Digest-verified reads use this: admission already proved
|
||||
* that these exact bytes decode completely, so the read path only re-derives
|
||||
* the reference fields instead of paying the full-raster decode again.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
|
||||
try {
|
||||
return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false }))
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully decode a supported raster and return its intrinsic metadata.
|
||||
* @param data - complete encoded image bytes.
|
||||
* @param maxPixels - decoded-pixel admission limit.
|
||||
* @returns verified format and dimensions.
|
||||
*/
|
||||
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
|
||||
try {
|
||||
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
|
||||
const detected = await imageMetadata(image)
|
||||
if (maxPixels !== undefined && detected.width * detected.height > maxPixels) {
|
||||
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
}
|
||||
await image.raw().toBuffer()
|
||||
return detected
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
|
||||
}
|
||||
}
|
||||
76
packages/attachment/attachment-local/src/index.ts
Normal file
76
packages/attachment/attachment-local/src/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
||||
|
||||
export { detectImage } from './image.ts'
|
||||
export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
|
||||
|
||||
/** Default maximum encoded bytes for one image. */
|
||||
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
/** Default maximum images in one prompt. */
|
||||
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
|
||||
/** Default maximum aggregate image bytes in one prompt. */
|
||||
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
/** Default maximum intrinsic pixels for one image. */
|
||||
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
|
||||
|
||||
/** Local attachment backend configuration. */
|
||||
export interface Config {
|
||||
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Maximum encoded bytes accepted for one image. */
|
||||
maxImageBytes?: number
|
||||
/** Maximum image count accepted in one submitted message. */
|
||||
maxImagesPerMessage?: number
|
||||
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
|
||||
maxMessageImageBytes?: number
|
||||
/** Maximum intrinsic width multiplied by height accepted for one image. */
|
||||
maxImagePixels?: number
|
||||
}
|
||||
|
||||
/** Persistent content-addressed local attachment store. */
|
||||
export class LocalAttachmentStore extends AttachmentStore {
|
||||
static Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES),
|
||||
maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE),
|
||||
maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
|
||||
maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
|
||||
})
|
||||
|
||||
/** Absolute versioned storage root. */
|
||||
readonly root: string
|
||||
readonly imageLimits: ImageAttachmentLimits
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1'))
|
||||
this.imageLimits = Object.freeze({
|
||||
maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES,
|
||||
maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||
maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
|
||||
maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS,
|
||||
mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
|
||||
})
|
||||
}
|
||||
|
||||
async validateImage(input: SaveImageAttachment): Promise<void> {
|
||||
await validateImageFile(input, this.imageLimits)
|
||||
}
|
||||
|
||||
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
return saveImageFile(this.root, input, this.imageLimits)
|
||||
}
|
||||
|
||||
async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref, signal)
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalAttachmentStore
|
||||
20
packages/attachment/attachment-local/src/invariant.ts
Normal file
20
packages/attachment/attachment-local/src/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local'
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'attachment-local-invariant'
|
||||
/** Services required before package ownership can be reserved. */
|
||||
export const inject = ['invariants', 'attachments']
|
||||
/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
/**
|
||||
* Register the package invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the registration disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
231
packages/attachment/attachment-local/src/store.ts
Normal file
231
packages/attachment/attachment-local/src/store.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/** Content-addressed, owner-private local attachment storage. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
|
||||
import { dirname, join, parse, resolve } from 'node:path'
|
||||
import {
|
||||
AttachmentError,
|
||||
AttachmentId,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { detectImage, probeImage } from './image.ts'
|
||||
|
||||
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
|
||||
const durableHomes = new Set<string>()
|
||||
|
||||
function digest(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
function displayName(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
// Strip both separator styles by hand: a POSIX host treats `\` as an
|
||||
// ordinary character, so path.basename would keep a Windows client's full
|
||||
// local path and leak it into the reference and the session log.
|
||||
const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
|
||||
const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
|
||||
return clean === '' ? undefined : clean
|
||||
}
|
||||
|
||||
function objectPath(root: string, sha256: string): string {
|
||||
return join(root, 'objects', sha256.slice(0, 2), sha256)
|
||||
}
|
||||
|
||||
function ensureReference(ref: ImageAttachmentRef): string {
|
||||
const match = ID_PATTERN.exec(String(ref.attachmentId))
|
||||
if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
|
||||
return match[1]
|
||||
}
|
||||
|
||||
async function inspectMetadata(
|
||||
data: Uint8Array,
|
||||
declaredMediaType: ImageAttachmentRef['mediaType'],
|
||||
maxPixels?: number,
|
||||
): Promise<Omit<ImageAttachmentRef, 'attachmentId' | 'name'>> {
|
||||
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
|
||||
const detected = await detectImage(data, maxPixels)
|
||||
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
|
||||
return { ...detected, bytes: data.byteLength }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full admission policy for one image without touching storage.
|
||||
* @param input - encoded bytes and declared metadata.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns completion after the encoded raster has been fully decoded.
|
||||
*/
|
||||
export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<void> {
|
||||
if (input.data.byteLength > limits.maxImageBytes) {
|
||||
throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
}
|
||||
await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a directory's entries durable (fsync on a read-only directory handle).
|
||||
* A synced file alone does not survive a crash when its directory entry never
|
||||
* reached storage, so the publication directory is synced before a durable
|
||||
* reference is reported.
|
||||
*/
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
/* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */
|
||||
if (process.platform === 'win32') return
|
||||
/* v8 ignore start -- Windows cannot exercise directory fsync; POSIX behavior tests enforce this peer. */
|
||||
const handle = await open(path, constants.O_RDONLY)
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one private directory tree and persist every ancestor entry up to a
|
||||
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
|
||||
* reports as newly created: a concurrent first save can create a level this
|
||||
* process then merely observes, so "already existed" is not "already durable"
|
||||
* — the entry may still be unsynced in the creator, and a crash would drop a
|
||||
* directory the session checkpoint already references. Re-syncing a durable
|
||||
* entry is harmless; skipping an unsynced one is not.
|
||||
* @param path - absolute directory to create.
|
||||
* @param boundary - absolute ancestor the caller vouches is already durable.
|
||||
*/
|
||||
async function ensureDurableDirectory(path: string, boundary: string): Promise<void> {
|
||||
const target = resolve(path)
|
||||
const stop = resolve(boundary)
|
||||
await mkdir(target, { recursive: true, mode: 0o700 })
|
||||
await chmod(target, 0o700)
|
||||
let level = target
|
||||
while (level !== stop) {
|
||||
const parent = dirname(level)
|
||||
await syncDirectory(parent)
|
||||
/* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */
|
||||
if (parent === level) return
|
||||
level = parent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish this process's proof that one DSH_HOME entry and every ancestor
|
||||
* below the filesystem root are durable. Mere existence is insufficient: a
|
||||
* concurrent process may have created the directory but not synced its parent.
|
||||
*/
|
||||
async function ensureDurableHome(path: string): Promise<string> {
|
||||
const home = resolve(path)
|
||||
if (!durableHomes.has(home)) {
|
||||
await ensureDurableDirectory(home, parse(home).root)
|
||||
durableHomes.add(home)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and verify immutable image bytes below a versioned attachment root.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param input - encoded bytes and declared metadata.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns durable content-addressed reference.
|
||||
*/
|
||||
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
|
||||
if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
// Establish DSH_HOME itself against the filesystem root once per process.
|
||||
// Every process performs that proof independently, so observing a directory
|
||||
// another process created can never be mistaken for durable publication.
|
||||
const boundary = await ensureDurableHome(dirname(dirname(resolve(root))))
|
||||
await ensureDurableDirectory(bucket, boundary)
|
||||
await ensureDurableDirectory(staging, boundary)
|
||||
const temporary = join(staging, randomUUID())
|
||||
const target = objectPath(root, sha256)
|
||||
let handle
|
||||
try {
|
||||
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
|
||||
await handle.writeFile(input.data)
|
||||
await handle.sync()
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
try {
|
||||
await link(temporary, target)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
|
||||
const existing = new Uint8Array(await readFile(target))
|
||||
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
// Persist the target entry and close a concurrent bucket-creation window
|
||||
// before the reference can reach a session checkpoint. The dedup path
|
||||
// repeats both syncs because it may observe another writer's link before
|
||||
// that writer reaches its own durability boundary.
|
||||
await syncDirectory(bucket)
|
||||
await syncDirectory(join(root, 'objects'))
|
||||
await unlink(temporary)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */
|
||||
if (handle !== undefined) await handle.close().catch(
|
||||
/* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */
|
||||
() => {},
|
||||
)
|
||||
await unlink(temporary).catch(
|
||||
/* v8 ignore next -- The callback requires a second independent staging-unlink failure. */
|
||||
(cleanupError: unknown) => {
|
||||
/* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */
|
||||
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
|
||||
},
|
||||
)
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
|
||||
}
|
||||
const name = displayName(input.name)
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${sha256}`),
|
||||
...metadata,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and verify one content-addressed image.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param ref - reference recorded in the session log.
|
||||
* @param signal - optional cancellation for filesystem and verification work.
|
||||
* @returns verified bytes and reference.
|
||||
* @throws the signal reason when aborted, or an AttachmentError when verification fails.
|
||||
*/
|
||||
export async function readImageFile(
|
||||
root: string,
|
||||
ref: ImageAttachmentRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
const sha256 = ensureReference(ref)
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
// The digest proves these are the exact bytes admission fully decoded, so
|
||||
// the read path only re-derives the header fields (no raster decode, no
|
||||
// per-request pixel amplification on history replay).
|
||||
const metadata = await probeImage(data)
|
||||
signal?.throwIfAborted()
|
||||
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|
||||
|| metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
return { ref, data }
|
||||
}
|
||||
51
packages/attachment/attachment-local/tests/image.spec.ts
Normal file
51
packages/attachment/attachment-local/tests/image.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import sharp from 'sharp'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectImage, probeImage } from '../src/image.ts'
|
||||
|
||||
async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
|
||||
const image = sharp({
|
||||
create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } },
|
||||
})
|
||||
return new Uint8Array(await image.toFormat(format).toBuffer())
|
||||
}
|
||||
|
||||
describe('raster decoding', () => {
|
||||
it('decodes every supported format and its intrinsic dimensions', async () => {
|
||||
for (const [format, mediaType] of [
|
||||
['png', 'image/png'],
|
||||
['jpeg', 'image/jpeg'],
|
||||
['webp', 'image/webp'],
|
||||
['gif', 'image/gif'],
|
||||
] as const) {
|
||||
await expect(detectImage(await raster(format)))
|
||||
.resolves.toEqual({ mediaType, width: 3, height: 2 })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects excess decoded pixels before decoding', async () => {
|
||||
await expect(detectImage(await raster('png'), 5))
|
||||
.rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
})
|
||||
|
||||
it('rejects malformed bytes and truncated payloads with readable headers', async () => {
|
||||
await expect(detectImage(Uint8Array.of(1, 2, 3)))
|
||||
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const unsupported = await sharp({
|
||||
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).tiff().toBuffer()
|
||||
await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const complete = await raster('png')
|
||||
const truncated = complete.subarray(0, 62)
|
||||
await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 })
|
||||
await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
})
|
||||
|
||||
it('probes malformed bytes and unsupported formats into the same stable error', async () => {
|
||||
await expect(probeImage(Uint8Array.of(1, 2, 3)))
|
||||
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
const unsupported = await sharp({
|
||||
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).tiff().toBuffer()
|
||||
await expect(probeImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
})
|
||||
})
|
||||
60
packages/attachment/attachment-local/tests/index.spec.ts
Normal file
60
packages/attachment/attachment-local/tests/index.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import LocalAttachmentStore, {
|
||||
DEFAULT_MAX_IMAGE_BYTES,
|
||||
DEFAULT_MAX_IMAGE_PIXELS,
|
||||
DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||
DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
|
||||
} from '../src/index.ts'
|
||||
|
||||
describe('local attachment service', () => {
|
||||
it('resolves every omitted admission limit explicitly', () => {
|
||||
const service = new LocalAttachmentStore(new Context(), {})
|
||||
expect(service.imageLimits).toEqual({
|
||||
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
|
||||
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
|
||||
maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
|
||||
maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
})
|
||||
})
|
||||
|
||||
it('saves and reads through the service boundary', async () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-'))
|
||||
try {
|
||||
const service = new LocalAttachmentStore(new Context(), { dshHome })
|
||||
const data = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
const ref = await service.saveImage({ data, mediaType: 'image/png' })
|
||||
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates without persisting: a rejected image leaves no storage root behind', async () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
|
||||
try {
|
||||
const service = new LocalAttachmentStore(new Context(), { dshHome })
|
||||
await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }))
|
||||
.rejects.toThrow(/Unsupported or malformed image data/)
|
||||
const valid = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 })
|
||||
await expect(limited.validateImage({ data: valid, mediaType: 'image/png' }))
|
||||
.rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
|
||||
await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined()
|
||||
expect(existsSync(service.root)).toBe(false)
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
233
packages/attachment/attachment-local/tests/store.spec.ts
Normal file
233
packages/attachment/attachment-local/tests/store.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, parse, resolve } from 'node:path'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import sharp from 'sharp'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import { readImageFile, saveImageFile } from '../src/store.ts'
|
||||
|
||||
const fsControl = vi.hoisted(() => ({
|
||||
readSignals: [] as AbortSignal[],
|
||||
syncedDirectories: [] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
readFile(...args: Parameters<typeof actual.readFile>): ReturnType<typeof actual.readFile> {
|
||||
const options = args[1]
|
||||
if (typeof options === 'object' && options !== null) {
|
||||
const signal = (options as { signal?: AbortSignal }).signal
|
||||
if (signal !== undefined) fsControl.readSignals.push(signal)
|
||||
}
|
||||
return actual.readFile(...args)
|
||||
},
|
||||
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
|
||||
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
|
||||
return actual.open(...args)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const PNG = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
|
||||
const LIMITS: ImageAttachmentLimits = {
|
||||
maxImageBytes: 1024,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 2048,
|
||||
maxImagePixels: 16,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function root(): Promise<string> {
|
||||
const value = await mkdtemp(join(tmpdir(), 'dsh-attachment-'))
|
||||
roots.push(value)
|
||||
return join(value, 'attachments', 'v1')
|
||||
}
|
||||
|
||||
function parentChainToRoot(path: string): string[] {
|
||||
const parents: string[] = []
|
||||
let level = resolve(path)
|
||||
const root = parse(level).root
|
||||
while (level !== root) {
|
||||
level = dirname(level)
|
||||
parents.push(level)
|
||||
}
|
||||
return parents
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('local attachment store', () => {
|
||||
it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => {
|
||||
const storageRoot = await root()
|
||||
const base = join(storageRoot, '..', '..')
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const objects = join(storageRoot, 'objects')
|
||||
const bucket = join(objects, sha256.slice(0, 2))
|
||||
fsControl.syncedDirectories.length = 0
|
||||
|
||||
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
|
||||
// Each process first proves DSH_HOME durable all the way to the filesystem
|
||||
// root; existence alone cannot vouch for a concurrent creator's fsync.
|
||||
// Later directory creation can then stop at that process-proven boundary.
|
||||
expect(fsControl.syncedDirectories).toEqual([
|
||||
...parentChainToRoot(base),
|
||||
// bucket chain: every parent entry between the bucket and the boundary.
|
||||
objects,
|
||||
storageRoot,
|
||||
join(storageRoot, '..'),
|
||||
base,
|
||||
// staging chain re-walks the shared ancestors after creating tmp.
|
||||
storageRoot,
|
||||
join(storageRoot, '..'),
|
||||
base,
|
||||
// publication: the settled object's bucket and its parent for the rename.
|
||||
bucket,
|
||||
objects,
|
||||
])
|
||||
})
|
||||
|
||||
it('creates and persists a missing nested home directory against the filesystem root', async () => {
|
||||
const storageRoot = join(await root(), 'home', 'attachments', 'v1')
|
||||
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
|
||||
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
|
||||
})
|
||||
|
||||
it('publishes one private content-addressed object and deduplicates equal bytes', async () => {
|
||||
const storageRoot = await root()
|
||||
const first = await saveImageFile(storageRoot, {
|
||||
data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png',
|
||||
}, LIMITS)
|
||||
const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
|
||||
expect(first).toEqual({
|
||||
attachmentId: `sha256:${sha256}`,
|
||||
mediaType: 'image/png',
|
||||
bytes: PNG.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
name: 'pixel.png',
|
||||
})
|
||||
expect(second.attachmentId).toBe(first.attachmentId)
|
||||
expect(new Uint8Array(await readFile(object))).toEqual(PNG)
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(object)).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG })
|
||||
})
|
||||
|
||||
it('keeps admitted history readable after deployment limits become stricter', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
|
||||
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
|
||||
})
|
||||
|
||||
it('forwards read cancellation to the filesystem and preserves its reason', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
const controller = new AbortController()
|
||||
fsControl.readSignals.length = 0
|
||||
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG })
|
||||
expect(fsControl.readSignals).toEqual([controller.signal])
|
||||
|
||||
const cancellation = new Error('attachment read cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
|
||||
const storageRoot = await root()
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: new Uint8Array(0), mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: Uint8Array.of(1, 2, 3), mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: PNG, mediaType: 'image/jpeg',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' })
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: PNG, mediaType: 'image/png',
|
||||
}, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
|
||||
|
||||
const wide = new Uint8Array(await sharp({
|
||||
create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
}).png().toBuffer())
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
data: wide, mediaType: 'image/png',
|
||||
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
const unnamed = await saveImageFile(storageRoot, {
|
||||
data: PNG, mediaType: 'image/png', name: '\u0000',
|
||||
}, LIMITS)
|
||||
expect(unnamed).not.toHaveProperty('name')
|
||||
})
|
||||
|
||||
it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
const sha256 = String(ref.attachmentId).slice('sha256:'.length)
|
||||
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await chmod(object, 0o600)
|
||||
await writeFile(object, Uint8Array.of(1, 2, 3))
|
||||
await expect(readImageFile(storageRoot, ref))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
|
||||
await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }))
|
||||
.rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' })
|
||||
|
||||
const missingRoot = await root()
|
||||
await mkdir(missingRoot, { recursive: true })
|
||||
await expect(readImageFile(missingRoot, ref))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' })
|
||||
|
||||
const unreadableRoot = await root()
|
||||
const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await mkdir(target, { recursive: true })
|
||||
await expect(readImageFile(unreadableRoot, ref))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' })
|
||||
})
|
||||
|
||||
it('rejects conflicting existing objects and reference metadata mismatches', async () => {
|
||||
const storageRoot = await root()
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true })
|
||||
await writeFile(target, Uint8Array.of(1, 2, 3))
|
||||
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
|
||||
|
||||
await writeFile(target, PNG)
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 }))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
|
||||
})
|
||||
|
||||
it('maps unexpected publication failures to a stable storage error', async () => {
|
||||
const storageRoot = await root()
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
|
||||
await mkdir(target, { recursive: true })
|
||||
|
||||
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
|
||||
})
|
||||
})
|
||||
12
packages/attachment/attachment-local/tsconfig.json
Normal file
12
packages/attachment/attachment-local/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../attachment" },
|
||||
{ "path": "../../util/paths" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
6
packages/attachment/attachment/README.i18n.yaml
Normal file
6
packages/attachment/attachment/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
|
||||
README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
|
||||
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e
|
||||
21
packages/attachment/attachment/README.md
Normal file
21
packages/attachment/attachment/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-attachment
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Adding an image changes the provider request and therefore invalidates the affected request suffix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Version one accepts PNG, JPEG, WebP, and GIF only.
|
||||
- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects.
|
||||
- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts.
|
||||
21
packages/attachment/attachment/README.zh.md
Normal file
21
packages/attachment/attachment/README.zh.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# @deepseek-ai/dsh-attachment
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
添加图片会改变提供方请求,因此会使受影响的请求后缀失效。
|
||||
|
||||
## 已知限制与待完成工作
|
||||
|
||||
- 第一版仅接受 PNG、JPEG、WebP 和 GIF。
|
||||
- 保留策略与垃圾回收尚未实现,因为恢复和 fork 后的会话可能共享不可变对象。
|
||||
- 通用文件、音频、视频和持久的未发送草稿需要单独的生命周期与提供方契约。
|
||||
34
packages/attachment/attachment/package.json
Normal file
34
packages/attachment/attachment/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/attachment/attachment"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" },
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
15
packages/attachment/attachment/src/brand.ts
Normal file
15
packages/attachment/attachment/src/brand.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Opaque content-addressed identifier for one immutable attachment object. */
|
||||
export type AttachmentId = Branded<'AttachmentId'>
|
||||
|
||||
/**
|
||||
* Brand a validated storage identifier.
|
||||
* @param value - backend-produced opaque identifier.
|
||||
* @returns the branded identifier.
|
||||
*/
|
||||
export function AttachmentId(value: string): AttachmentId {
|
||||
return value as AttachmentId
|
||||
}
|
||||
26
packages/attachment/attachment/src/error.ts
Normal file
26
packages/attachment/attachment/src/error.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
|
||||
|
||||
/**
|
||||
* Stable failures suitable for host RPC error mapping.
|
||||
*
|
||||
* Deliberately re-implements the `HarnessError` shape instead of extending it:
|
||||
* the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this
|
||||
* package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base
|
||||
* would create a dependency cycle. Consumers route on `code`, never on the
|
||||
* prototype chain, so the shapes stay interchangeable at the wire boundary.
|
||||
*/
|
||||
export class AttachmentError extends Error {
|
||||
/** Stable machine-routing failure code. */
|
||||
readonly code: string
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure description without raw bytes or host paths.
|
||||
* @param code - stable machine-routing code.
|
||||
* @param options - optional chained cause.
|
||||
*/
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AttachmentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
62
packages/attachment/attachment/src/index.ts
Normal file
62
packages/attachment/attachment/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from './types.ts'
|
||||
|
||||
export { AttachmentId } from './brand.ts'
|
||||
export { AttachmentError } from './error.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
ImageMediaType,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
attachments: AttachmentStore
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */
|
||||
export abstract class AttachmentStore extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'attachments')
|
||||
}
|
||||
|
||||
/** Deployment-resolved image policy used by authoritative and fast-path validation. */
|
||||
abstract readonly imageLimits: ImageAttachmentLimits
|
||||
|
||||
/**
|
||||
* Validate one image without persisting it.
|
||||
* Batch callers validate every member before saving any member.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
* @returns completion after the encoded raster has been fully decoded.
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
* @returns a durable content-addressed reference.
|
||||
*/
|
||||
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
||||
|
||||
/**
|
||||
* Read one image and verify that bytes still match the recorded reference.
|
||||
* @param ref - durable reference from the session log.
|
||||
* @param signal - optional cancellation for backend read and verification work.
|
||||
* @returns the verified bytes and canonical reference.
|
||||
* @throws the signal reason when aborted, or a storage error when verification fails.
|
||||
*/
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
}
|
||||
|
||||
export default AttachmentStore
|
||||
20
packages/attachment/attachment/src/invariant.ts
Normal file
20
packages/attachment/attachment/src/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-attachment'
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'attachment-invariant'
|
||||
/** Service required before package ownership can be reserved. */
|
||||
export const inject = ['invariants']
|
||||
/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
/**
|
||||
* Register the package invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the registration disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
48
packages/attachment/attachment/src/types.ts
Normal file
48
packages/attachment/attachment/src/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */
|
||||
|
||||
import type { AttachmentId } from './brand.ts'
|
||||
|
||||
export type { AttachmentId } from './brand.ts'
|
||||
|
||||
/** Raster image formats accepted by the version-one attachment path. */
|
||||
export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
||||
|
||||
/** Durable, serializable metadata for one immutable image object. */
|
||||
export interface ImageAttachmentRef {
|
||||
/** Opaque storage identifier; never a filesystem path or bearer URL. */
|
||||
attachmentId: AttachmentId
|
||||
/** Media type verified from the stored bytes. */
|
||||
mediaType: ImageMediaType
|
||||
/** Exact encoded byte length. */
|
||||
bytes: number
|
||||
/** Intrinsic encoded width in pixels. */
|
||||
width: number
|
||||
/** Intrinsic encoded height in pixels. */
|
||||
height: number
|
||||
/** Optional display name stripped of local path information. */
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Deployment-resolved limits used by upload admission and request buffering. */
|
||||
export interface ImageAttachmentLimits {
|
||||
maxImageBytes: number
|
||||
maxImagesPerMessage: number
|
||||
maxMessageImageBytes: number
|
||||
maxImagePixels: number
|
||||
mediaTypes: readonly ImageMediaType[]
|
||||
}
|
||||
|
||||
/** Request to validate and durably commit one image. */
|
||||
export interface SaveImageAttachment {
|
||||
data: Uint8Array
|
||||
/** Caller-declared media type, checked against fully decoded bytes. */
|
||||
mediaType: ImageMediaType
|
||||
/** Optional browser/provider display name; it is never interpreted as a path. */
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Stored image bytes returned after reference and digest verification. */
|
||||
export interface StoredImageAttachment {
|
||||
ref: ImageAttachmentRef
|
||||
data: Uint8Array
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../helper" },
|
||||
{ "path": "../telemetry" },
|
||||
{ "path": "../../boot/app-boot" },
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-env/README.md
|
||||
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
|
||||
README.zh.md: 4d80d9d34f2be18e07d57d2427eb841f61f1ccfc
|
||||
README.md: 54758a91773aad723c6fbbebe2ffe25aedb38599
|
||||
README.zh.md: e0663b003e94942c76cde4fb7867ed0ce74c2aa2
|
||||
|
||||
@@ -22,7 +22,7 @@ Every foreground and background model shell call receives a newly collected trus
|
||||
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
`ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
export const inject = ['bashEnv']
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-env",
|
||||
"description": "Tool-independent managed DSH_* shell environment registry",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/bash-env"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,15 +32,15 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
@@ -43,6 +50,6 @@
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
* @module @deepseek-ai/dsh-bash-env
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
bashEnv: BashEnvRegistry
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
|
||||
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
|
||||
README.md: 253d1c9efb518ca204956f062b2f529f4e83531b
|
||||
README.zh.md: 5a0d943a08d1da6c73ad07f469c056180b476ff3
|
||||
|
||||
@@ -25,7 +25,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-local",
|
||||
"description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/bash-local"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,14 +32,14 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
@@ -40,6 +47,6 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
@@ -307,7 +307,7 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
|
||||
// The child prints its own pid ($$ = the detached bash group leader) so
|
||||
// the test can probe liveness through the public read surface alone.
|
||||
// the test can probe liveness through the public read API alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/bash-sandbox"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,12 +32,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
@@ -40,7 +47,7 @@
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/node-addon-landlock-run": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
@@ -125,7 +125,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async
|
||||
// `done` rejection; this covers alternatives that throw that shape synchronously.
|
||||
// `done` rejection; this covers alternatives that throw the same error synchronously.
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
|
||||
@@ -8,7 +8,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
|
||||
README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a
|
||||
README.md: a076c6ef3150de86c251f0501b18de01c4491c67
|
||||
README.zh.md: 1be9f817a8379b2974fb4cfd63c197c320934f00
|
||||
|
||||
@@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives with the Service Definition so the two tools never drift on the marker contract.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 Service Definition 中,两个工具便永远不会在 marker 约定上漂移。
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-bash",
|
||||
"description": "Abstract bash executor seam (ctx.bash) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/bash"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,15 +32,15 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-bash
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
@@ -25,7 +25,7 @@ export type {
|
||||
export { parseExitStatus } from './render.ts'
|
||||
export type { ParsedExitStatus } from './render.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
bash: BashExecutor
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-bash/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-bash'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-local",
|
||||
"description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/pwsh-local"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,14 +32,14 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
@@ -40,6 +47,6 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
* @module @deepseek-ai/dsh-pwsh-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
@@ -162,12 +162,27 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one resolved bash spec onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec {
|
||||
/**
|
||||
* The pwsh invocation argv for one resolved spec — the argv-level seam a
|
||||
* confining subclass wraps through `ctx.sandbox.confine` (the pwsh twin of
|
||||
* `dsh-bash-local`'s `runArgv`/`startArgv` hooks; see
|
||||
* `@deepseek-ai/dsh-pwsh-sandbox`).
|
||||
*/
|
||||
protected argv(spec: BashExecSpec): string[] {
|
||||
return [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`]
|
||||
}
|
||||
|
||||
/** Map one resolved spec plus its argv onto a fully-specified subprocess spawn. */
|
||||
private spawnSpec(
|
||||
spec: BashExecSpec,
|
||||
stdoutMaxBytes: number,
|
||||
signal: AbortSignal | undefined,
|
||||
argv: readonly string[],
|
||||
): SubprocessSpawnSpec {
|
||||
const collect = (maxBytes: number): SubprocessCollect =>
|
||||
({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } })
|
||||
return {
|
||||
argv: [this.pwshPath, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${spec.command}`],
|
||||
argv: [...argv],
|
||||
cwd: spec.workdir,
|
||||
stdio: {
|
||||
stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
|
||||
@@ -192,9 +207,14 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return this.runArgv(spec, this.argv(spec))
|
||||
}
|
||||
|
||||
/** Foreground run of an exact argv (the confining subclass re-wraps it). */
|
||||
protected async runArgv(spec: BashExecSpec, argv: readonly string[]): Promise<BashRunResult> {
|
||||
// One deadline combines timeout and upstream cancellation; disposal clears its timer.
|
||||
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal))
|
||||
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal, argv))
|
||||
const outcome = await handle.done
|
||||
const collected = PwshLocalExecutor.collected(handle)
|
||||
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
|
||||
@@ -211,8 +231,13 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashProcess {
|
||||
return this.startArgv(spec, this.argv(spec))
|
||||
}
|
||||
|
||||
/** Background start of an exact argv (the confining subclass re-wraps it). */
|
||||
protected startArgv(spec: BashExecSpec, argv: readonly string[]): BashProcess {
|
||||
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal))
|
||||
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal, argv))
|
||||
const collected = PwshLocalExecutor.collected(running)
|
||||
|
||||
// A spawn failure produces no process output, so the subprocess service has nothing
|
||||
@@ -237,12 +262,12 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
}
|
||||
proc.exitCode = outcome.exitCode
|
||||
proc.signal = outcome.signal
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text)
|
||||
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
|
||||
}, (error: unknown) => {
|
||||
// Background spawn failures settle as killed and surface through the read path.
|
||||
proc.status = 'killed'
|
||||
spawnFailureNote = `spawn failed: ${String(error)}`
|
||||
this.onProcessDone(proc, spawnFailureNote)
|
||||
this.onProcessDone(proc, spawnFailureNote, true, error)
|
||||
}),
|
||||
readOutput: (): BashProcessRead => {
|
||||
const out = collected.stdout.readFrom(stdoutOffset)
|
||||
@@ -278,13 +303,14 @@ export class PwshLocalExecutor extends BashExecutor {
|
||||
/**
|
||||
* Settlement hook for subclasses that attach execution facts to a process.
|
||||
* The base implementation is intentionally empty. Mirrored from
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); it is
|
||||
* the protected extension point for a future pwsh-confining subclass and has no consumer
|
||||
* in this package yet.
|
||||
* `dsh-bash-local` (whose sandboxing subclass consumes the same hook); the
|
||||
* pwsh-confining consumer is `@deepseek-ai/dsh-pwsh-sandbox`.
|
||||
* @param _proc - the settled process handle.
|
||||
* @param _stderr - the process's retained stderr tail used by subclasses for settlement classification.
|
||||
* @param _spawnFailed - whether the spawn rejected before any process existed.
|
||||
* @param _spawnError - the spawn rejection, when `_spawnFailed`.
|
||||
*/
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string): void {}
|
||||
protected onProcessDone(_proc: BashProcess, _stderr: string, _spawnFailed: boolean, _spawnError?: unknown): void {}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
* writes CRLF on Windows, so exact text assertions normalize line endings.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SubprocessService from '@deepseek-ai/dsh-subprocess'
|
||||
@@ -33,7 +33,9 @@ const lf = (text: string): string => text.replace(/\r\n/g, '\n')
|
||||
|
||||
/** Case-insensitive path equality on Windows (Get-Location may re-case the drive). */
|
||||
function samePath(actual: string, expected: string): boolean {
|
||||
const norm = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
|
||||
const norm = (value: string) => (
|
||||
process.platform === 'win32' ? realpathSync.native(value).toLowerCase() : value
|
||||
)
|
||||
return norm(actual) === norm(expected)
|
||||
}
|
||||
|
||||
@@ -72,7 +74,11 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
|
||||
it('falls through an empty configured path to platform resolution', () => {
|
||||
// SystemRoot points at a non-existent tree so the Windows PowerShell 5.1
|
||||
// fallback candidate cannot exist either.
|
||||
expect(resolvePwshPath('', { PATH: 'P:\\Store', SystemRoot: 'S:\\no-windows' }, 'win32')).toBe('pwsh')
|
||||
expect(resolvePwshPath('', {
|
||||
PATH: 'P:\\Store',
|
||||
ProgramFiles: 'P:\\no-program-files',
|
||||
SystemRoot: 'S:\\no-windows',
|
||||
}, 'win32')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('returns pwsh on non-Windows platforms regardless of the environment', () => {
|
||||
@@ -80,6 +86,13 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
|
||||
expect(resolvePwshPath(undefined, { PATH: 'P:\\Store' }, 'darwin')).toBe('pwsh')
|
||||
})
|
||||
|
||||
it('uses stable Windows roots when the environment omits both overrides', () => {
|
||||
expect(candidatePwshPaths({})).toEqual([
|
||||
join('C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'),
|
||||
join('C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
])
|
||||
})
|
||||
|
||||
it('lists PowerShell 7, PATH entries (quotes stripped), then Windows PowerShell 5.1 on win32', () => {
|
||||
const candidates = candidatePwshPaths({
|
||||
ProgramFiles: 'P:\\Program Files',
|
||||
@@ -154,12 +167,12 @@ describe('spawn construction (pure, every platform)', () => {
|
||||
})
|
||||
|
||||
describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
|
||||
it('resolves with output and the effective timeout', async () => {
|
||||
const { bash } = await setup({ timeoutMs: 5_000 })
|
||||
it('resolves with output and the effective timeout', { timeout: 15_000 }, async () => {
|
||||
const { bash } = await setup({ timeoutMs: 10_000 })
|
||||
const result = await bash.run(bash.resolve({ command: 'Write-Output hi' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(lf(result.stdout.text)).toBe('hi\n')
|
||||
expect(result.timeoutMs).toBe(5_000)
|
||||
expect(result.timeoutMs).toBe(10_000)
|
||||
})
|
||||
|
||||
it('uses config cwd, overridable per call', async () => {
|
||||
@@ -301,9 +314,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)'
|
||||
env: { BG_VAR: 'bg-env' },
|
||||
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
|
||||
}))
|
||||
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
const partialOutput = await readUntil(proc, '[bg-env][bg-dsh-env]')
|
||||
await proc.done
|
||||
const output = partialOutput + lf(proc.readOutput().delta)
|
||||
expect(output).toBe('bg-stdin\n[bg-env][bg-dsh-env]\n')
|
||||
expect(proc.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
6
packages/bash/pwsh-sandbox/README.i18n.yaml
Normal file
6
packages/bash/pwsh-sandbox/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md
|
||||
README.md: c47409c122ffce264f53fc41c786da015fdaab6b
|
||||
README.zh.md: 5b14185a71943d0a0a50f0bae353a3e22d0f1fb2
|
||||
34
packages/bash/pwsh-sandbox/README.md
Normal file
34
packages/bash/pwsh-sandbox/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Sandbox-consuming PowerShell implementation of the [`ctx.bash` executor seam](../bash/): every command runs as `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` **confined through `ctx.sandbox`**, with the selected mode, enforcement, and denial facts stamped on each settled result. The pwsh twin of [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/), a call-for-call mirror per the [pwsh executor and tool decision](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) — the confinement substance is platform-neutral: on Windows the sandbox seam resolves to the ACL restricted-token runner chain ([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)), on Linux/macOS to bwrap/Landlock/Seatbelt.
|
||||
|
||||
The executor inherits [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/)'s process mechanics and consumes its argv-level seam (`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`) to wrap the exact pwsh invocation through the provider. The sandbox policy (mode + workspace root) is NOT this package's config: it rides each call from `ctx.sandboxPolicy` (tool calls pass the calling session's resolved policy; direct calls fall back to deployment policy).
|
||||
|
||||
## Behavior
|
||||
|
||||
- `danger-full-access`: commands run through the local executor unchanged; results carry `sandbox: { mode, denied: false }`.
|
||||
- Confined modes (`read-only`, `workspace-write`): the pwsh argv is wrapped by `ctx.sandbox.confine()`; runner-launch refusal fails closed with `SANDBOX_UNAVAILABLE` (foreground throw, background `runnerFailed` fact), and a denied write classifies against the selected backend's `denialSignatures` into `sandbox.denied`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Confinement works, denial surfaces as command failure
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The confined command's own stderr (e.g. `Access to the path '...' is denied.` under the Windows ACL runner); the tool layer converts classified denials into the standard permission-denied surface exactly as it does for the bash tool.
|
||||
|
||||
#### Token effect
|
||||
|
||||
No model-visible text beyond the command's stderr and the tool layer's standard denial surface.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None directly; the denial surface belongs to the tool layer.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning.
|
||||
- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL.
|
||||
34
packages/bash/pwsh-sandbox/README.zh.md
Normal file
34
packages/bash/pwsh-sandbox/README.zh.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pwsh-sandbox
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
沙盒消费型的 [`ctx.bash` 执行器 seam](../bash/) 的 PowerShell 实现:每条命令以 `pwsh -NoLogo -NoProfile -NonInteractive -Command <command>` 运行,**经 `ctx.sandbox` 隔离**,选定模式、强制完整性、拒绝事实都盖在每次结算的结果上。它是 [`@deepseek-ai/dsh-bash-sandbox`](../bash-sandbox/) 的 pwsh 孪生,按 [pwsh 执行器与工具决策](../../../.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 逐调用镜像——隔离实体本身是平台无关的:Windows 上沙盒 seam 解析到 ACL 受限令牌 runner 链([`@deepseek-ai/dsh-sandbox-windows-acl`](../../sandbox/sandbox-windows-acl/)),Linux/macOS 上解析到 bwrap/Landlock/Seatbelt。
|
||||
|
||||
执行器继承 [`@deepseek-ai/dsh-pwsh-local`](../pwsh-local/) 的进程机制,并消费其 argv 级 seam(`argv()` / `runArgv()` / `startArgv()` / `onProcessDone()`)把精确的 pwsh 调用经 provider 包装。沙盒策略(模式 + 工作区根目录)不是本包的配置:每次调用由 `ctx.sandboxPolicy` 随行(工具层传调用会话解析后的策略;直接调用回退到部署策略)。
|
||||
|
||||
## 行为
|
||||
|
||||
- `danger-full-access`:命令经本地执行器原样运行;结果携带 `sandbox: { mode, denied: false }`。
|
||||
- 受限模式(`read-only`、`workspace-write`):pwsh argv 由 `ctx.sandbox.confine()` 包装;runner 启动失败按 fail-closed 抛 `SANDBOX_UNAVAILABLE`(前台抛错、后台记 `runnerFailed` 事实),被拒绝的写按所选后端的 `denialSignatures` 分类为 `sandbox.denied`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 隔离生效,拒绝以命令失败呈现
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
受限命令自身的 stderr(Windows ACL runner 下如 `Access to the path '...' is denied.`);工具层把分类后的拒绝转成标准权限拒绝面,与 bash 工具完全一致。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
除命令 stderr 与工具层标准拒绝面外,无额外模型可见文本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;拒绝呈现面属于工具层。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent(智能体)的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权,runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。
|
||||
- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源;PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。
|
||||
52
packages/bash/pwsh-sandbox/package.json
Normal file
52
packages/bash/pwsh-sandbox/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pwsh-sandbox",
|
||||
"description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/pwsh-sandbox"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
120
packages/bash/pwsh-sandbox/src/helpers.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Internal sandbox-result classification helpers — deliberate call-for-call
|
||||
* mirror of `@deepseek-ai/dsh-bash-sandbox/src/helpers.ts` (the pwsh twin of
|
||||
* the bash consumer shares the identical classification dialect).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox/helpers
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** Node-local spawn codes proven to identify executable resolution or permission failure. */
|
||||
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
|
||||
|
||||
/** Whether the caller-owned spawn cwd can be entered. */
|
||||
function isUsableWorkdir(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute only Node ENOENT/EACCES failures with positive argv[0] provenance
|
||||
* after independently ruling out the caller-owned cwd. A supplied error path
|
||||
* must exactly identify the runner; without one, the syscall must. With a
|
||||
* usable cwd, these codes describe resolution or execute permission for that
|
||||
* argv[0] or its shebang interpreter.
|
||||
* The workdir is checked at classification time, not atomically with spawn;
|
||||
* concurrent path replacement may change attribution but cannot permit an
|
||||
* unconfined execution.
|
||||
* @param error - the original spawn rejection.
|
||||
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
|
||||
* @param workdir - the caller-owned spawn cwd, checked independently for usability.
|
||||
* @returns whether the rejection has executable-specific runner evidence.
|
||||
*/
|
||||
export function isRunnerSpawnFailure(
|
||||
error: unknown,
|
||||
runnerProgram: string | undefined,
|
||||
workdir: string,
|
||||
): boolean {
|
||||
if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
const { code, path, syscall } = error as { code?: unknown; path?: unknown; syscall?: unknown }
|
||||
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
|
||||
if (typeof syscall !== 'string') return false
|
||||
const exactSyscall = `spawn ${runnerProgram}`
|
||||
if (path === undefined) return syscall === exactSyscall
|
||||
if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
|
||||
return syscall === 'spawn' || syscall === exactSyscall
|
||||
}
|
||||
|
||||
/** Fatal runner evidence retained for infrastructure-error detail. */
|
||||
interface RunnerFailureMatch {
|
||||
/** The original stderr line that matched a fatal signature. */
|
||||
detail: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's denial dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive denial substrings from the active wrap.
|
||||
* @returns whether the failed run matches that denial dialect.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one settled process against the selected backend's structured
|
||||
* runner-failure rules. Each rule requires a nonzero exit, its optional
|
||||
* exit-code gate, and a fatal signature on one stderr line after exact
|
||||
* informational lines are excluded.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text, left unchanged.
|
||||
* @param rules - structured runner-failure rules from the active wrap.
|
||||
* @returns the first matching fatal line, or undefined when evidence is insufficient.
|
||||
*/
|
||||
export function classifyRunnerFailure(
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
rules: readonly RunnerFailureRule[],
|
||||
): RunnerFailureMatch | undefined {
|
||||
if (exitCode === null || exitCode === 0) return undefined
|
||||
const lines = stderr.split(/\r?\n/)
|
||||
for (const rule of rules) {
|
||||
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
|
||||
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
|
||||
// An empty or whitespace-only substring is not meaningful runner evidence.
|
||||
// Ignore it while keeping any valid signatures beside it active.
|
||||
const fatalSignatures = rule.fatalSignatures
|
||||
.filter(signature => signature.trim().length > 0)
|
||||
.map(signature => signature.toLowerCase())
|
||||
for (const line of lines) {
|
||||
const lowered = line.toLowerCase()
|
||||
if (informationalLines.has(lowered)) continue
|
||||
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a non-zero exit against case-insensitive stderr signatures.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text.
|
||||
* @param signatures - substrings identifying the selected backend's dialect.
|
||||
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
||||
*/
|
||||
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
189
packages/bash/pwsh-sandbox/src/index.ts
Normal file
189
packages/bash/pwsh-sandbox/src/index.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Sandbox-consuming PowerShell executor — the pwsh twin of
|
||||
* `@deepseek-ai/dsh-bash-sandbox`. It wraps the exact local pwsh argv through
|
||||
* `ctx.sandbox` (which on Windows resolves to the ACL restricted-token runner
|
||||
* chain), inherits local process mechanics, and reports the selected mode,
|
||||
* enforcement, and denial facts. Positive runner-launch evidence means the
|
||||
* command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
|
||||
* background processes carry `runnerFailed`; other spawn rejections retain
|
||||
* local-executor semantics. The tool layer owns the escalation approval flow
|
||||
* through `ctx.approval`; this executor reports the sandbox facts the tool
|
||||
* renders.
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
ConfinedArgv,
|
||||
ConfinedSandboxMode,
|
||||
RunnerFailureRule,
|
||||
SandboxEnforcement,
|
||||
SandboxExecutionPolicy,
|
||||
SandboxMode,
|
||||
SandboxPolicy,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and fallback `workspace-write` root — is NOT here: it lives
|
||||
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
|
||||
* each calling session's mode and cwd for every enforcing capability. The
|
||||
* runner choice is likewise the `ctx.sandbox` provider's config, not this
|
||||
* executor's.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local pwsh executor and requires a
|
||||
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer carries the
|
||||
* sandbox denial rendering and escalation surface (see the
|
||||
* pwsh-tool-and-executor Agent Note). Tool calls pass the calling session's
|
||||
* resolved policy; direct calls fall back to deployment policy.
|
||||
* `result.sandbox` reports the mode, enforcement, and denial facts the tool
|
||||
* renders.
|
||||
*/
|
||||
/* jscpd:ignore-start -- deliberate call-for-call mirror of bash-sandbox's executor (pwsh-tool-and-executor Agent Note) */
|
||||
export class SandboxPwshExecutor extends PwshLocalExecutor {
|
||||
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits PwshLocalExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
/**
|
||||
* Per-process confinement facts retained until settlement. Providers may
|
||||
* vary enforcement and diagnostic dialect between overlapping calls, so a
|
||||
* shared latest-wrap value would classify a process against the wrong facts.
|
||||
* Unconfined processes have no entry.
|
||||
*/
|
||||
private readonly processFacts = new Map<BashProcess, {
|
||||
mode: ConfinedSandboxMode
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureRules: readonly RunnerFailureRule[]
|
||||
runnerProgram: string | undefined
|
||||
workdir: string
|
||||
}>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// The default mode is the capability fact used for schema advertisement;
|
||||
// actual tool executions carry their resolved per-call policy.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a complete per-call policy onto the spec. Tool calls supply the
|
||||
* calling session's resolved mode and root; lower-level callers fall back to
|
||||
* the deployment policy.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec, { ...policy, mode })
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await this.runArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
// An upstream abort remains cancellation even when it prevents spawn.
|
||||
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Runner failure outranks denial because the command did not run. Carry
|
||||
// the matched fatal line, not an informational line that preceded it.
|
||||
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
|
||||
if (runnerFailure !== undefined) {
|
||||
throw new SandboxUnavailableError(mode, runnerFailure.detail)
|
||||
}
|
||||
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Once startArgv returns, install facts synchronously; promise settlement
|
||||
// cannot run before start() returns.
|
||||
const confined = this.confine(spec, { ...policy, mode })
|
||||
let proc: BashProcess
|
||||
try {
|
||||
proc = this.startArgv(spec, confined.argv)
|
||||
} catch (error) {
|
||||
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
|
||||
throw new SandboxUnavailableError(mode, String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const { enforcement, denialSignatures, runnerFailureRules } = confined
|
||||
this.processFacts.set(proc, {
|
||||
mode,
|
||||
enforcement,
|
||||
denialSignatures,
|
||||
runnerFailureRules,
|
||||
runnerProgram: confined.argv[0],
|
||||
workdir: spec.workdir,
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp per-process sandbox facts before `done` settles. Full-access
|
||||
* processes have no facts; signal deaths are not denials.
|
||||
*/
|
||||
protected override onProcessDone(proc: BashProcess, stderr: string, spawnFailed: boolean, spawnError?: unknown): void {
|
||||
const facts = this.processFacts.get(proc)
|
||||
if (facts !== undefined) {
|
||||
this.processFacts.delete(proc)
|
||||
// A rejected spawn never started the confined launch. Otherwise runner
|
||||
// failure outranks denial because its diagnostics may contain denial terms.
|
||||
const runnerFailed = spawnFailed
|
||||
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir)
|
||||
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
|
||||
proc.sandbox = {
|
||||
mode: facts.mode,
|
||||
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
|
||||
enforcement: facts.enforcement,
|
||||
...(runnerFailed ? { runnerFailed } : {}),
|
||||
}
|
||||
}
|
||||
super.onProcessDone(proc, stderr, spawnFailed, spawnError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one pwsh invocation via the `ctx.sandbox` provider. Provider errors
|
||||
* propagate unchanged; the returned argv is handed directly to the local
|
||||
* executor's subprocess path.
|
||||
* @param spec - resolved execution spec whose pwsh argv is confined.
|
||||
* @param policy - resolved confined execution policy.
|
||||
* @returns the provider's exact argv and settlement-classification facts.
|
||||
*/
|
||||
private confine(spec: BashExecSpec, policy: SandboxPolicy): ConfinedArgv {
|
||||
return this.ctx.sandbox.confine(this.argv(spec), policy)
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export default SandboxPwshExecutor
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/create-sdk`.
|
||||
* @module @deepseek-ai/create-sdk/invariant
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pwsh-sandbox`.
|
||||
* @module @deepseek-ai/dsh-pwsh-sandbox/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/create-sdk'
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'create-sdk-invariant'
|
||||
export const name = 'pwsh-sandbox-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
* No runtime invariant: this package exposes no independent event sequence or
|
||||
* mutable data relation beyond contracts enforced at its owning seams.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
117
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
117
packages/bash/pwsh-sandbox/tests/acl.e2e.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
|
||||
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
|
||||
* REAL pwsh spawns confined through the runner — the debug-instance
|
||||
* verification of both modes on ordinary user-owned paths: read-only denies
|
||||
* writes, workspace-write allows its promised roots while denying escape
|
||||
* writes, and the partial-enforcement/denial facts ride the settled result.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { SandboxPwshExecutor } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let outsideTempDir!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let executor!: SandboxPwshExecutor
|
||||
|
||||
beforeAll(async () => {
|
||||
// The workspace escape sits under the profile. A separate directory under
|
||||
// the ambient temp root proves that the root itself is not granted: the
|
||||
// runner creates its own private child and rewrites TMP/TEMP to it.
|
||||
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: writableDir })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxPwshExecutor, {})
|
||||
executor = ctx.bash as SandboxPwshExecutor
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(outsideTempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
|
||||
// A self-caught denial keeps the command exit 0: no denial fact.
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
|
||||
// A raw failing write must classify as a denial of the ACL dialect.
|
||||
const denied = await executor.run(executor.resolve({
|
||||
command: `Set-Content -Path '${escapeFile}' -Value x`,
|
||||
sandboxPolicy: policy,
|
||||
}))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
}, 60_000)
|
||||
|
||||
it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
"try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
|
||||
`try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
||||
"'TEMP-PATH: ' + $env:TEMP",
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
|
||||
expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false)
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim()
|
||||
expect(privateTemp).toBeDefined()
|
||||
expect(privateTemp?.startsWith(tmpdir())).toBe(true)
|
||||
expect(existsSync(privateTemp ?? '')).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' })
|
||||
}, 60_000)
|
||||
})
|
||||
326
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
326
packages/bash/pwsh-sandbox/tests/sandbox.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Consumer-side `SandboxPwshExecutor` tests. A fake Cordis sandbox service
|
||||
* makes wrapping, policy hand-off, fail-closed propagation, and fact stamping
|
||||
* deterministic; real-provider integration lives in `tests/acl.e2e.ts`.
|
||||
* Requires pwsh for the integration block (skips without it — same gate as
|
||||
* pwsh-local's suites); the helpers block is pure and always runs.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { SandboxPwshExecutor } from '../src/index.ts'
|
||||
import { classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from '../src/helpers.ts'
|
||||
|
||||
// The same probe pwsh-local's suites and the vitest coverage exemption use:
|
||||
// spawnSync never throws on a missing binary (it reports status null), and
|
||||
// `where.exe pwsh` exits 1 when pwsh is absent — only the status is truth.
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-spec-'))
|
||||
|
||||
/** One recorded provider call: the argv handed over and the policy it rode with. */
|
||||
interface ConfineCall {
|
||||
argv: string[]
|
||||
policy: SandboxPolicy
|
||||
}
|
||||
|
||||
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
|
||||
const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
({ argv: [...argv], enforcement: 'full', denialSignatures: ['access is denied', 'access to the path'], runnerFailureRules: [] })
|
||||
|
||||
/** A subprocess service whose spawn() throws SYNCHRONOUSLY — the paths the async service never produces. */
|
||||
function throwingSubprocessService(error: unknown): new (ctx: Context) => Service {
|
||||
return class extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'subprocess')
|
||||
}
|
||||
|
||||
spawn(): never {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(
|
||||
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
|
||||
subprocess: new (ctx: Context) => Service = LocalSubprocessService,
|
||||
): Promise<{ executor: SandboxPwshExecutor; calls: ConfineCall[] }> {
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
calls.push({ argv: [...argv], policy })
|
||||
return behavior(argv, policy)
|
||||
}
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir })
|
||||
await ctx.plugin(subprocess)
|
||||
if (ctx.subprocess instanceof LocalSubprocessService) {
|
||||
ctx.subprocess.internals = { spillDir }
|
||||
}
|
||||
await ctx.plugin(SandboxPwshExecutor, { graceMs: 200 })
|
||||
return { executor: ctx.bash as SandboxPwshExecutor, calls }
|
||||
}
|
||||
|
||||
describe('helpers (pure)', () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-helpers-'))
|
||||
afterAll(() => {
|
||||
rmSync(workdir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('isRunnerSpawnFailure', () => {
|
||||
const absolute = process.execPath
|
||||
const bare = 'node'
|
||||
const relative = './sandbox-runner'
|
||||
|
||||
it('attributes ENOENT/EACCES with argv[0] provenance and a usable workdir', () => {
|
||||
for (const runnerProgram of [absolute, bare, relative]) {
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'EACCES', syscall: `spawn ${runnerProgram}`, path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: runnerProgram }, runnerProgram, workdir)).toBe(true)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: `spawn ${runnerProgram}` }, runnerProgram, workdir)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mismatched provenance, foreign codes, unusable workdirs, and non-object errors', () => {
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'other' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn other', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'EMFILE', syscall: 'spawn', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', path: 'node' }, 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, 'node', join(workdir, 'missing'))).toBe(false)
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn' }, undefined, workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure('boom', 'node', workdir)).toBe(false)
|
||||
expect(isRunnerSpawnFailure(null, 'node', workdir)).toBe(false)
|
||||
// An existing FILE (not a directory) workdir is unusable without throwing.
|
||||
const fileWorkdir = join(workdir, 'a-file')
|
||||
writeFileSync(fileWorkdir, 'x')
|
||||
expect(isRunnerSpawnFailure({ code: 'ENOENT', syscall: 'spawn', path: 'node' }, 'node', fileWorkdir)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyRunnerFailure', () => {
|
||||
const rules: readonly RunnerFailureRule[] = [{
|
||||
allowedExitCodes: [127],
|
||||
fatalSignatures: ['fake-runner: '],
|
||||
informationalLines: ['fake-runner: partial enforcement'],
|
||||
}]
|
||||
|
||||
it('matches a fatal signature on a gated exit code, skipping informational lines', () => {
|
||||
expect(classifyRunnerFailure(127, 'fake-runner: partial enforcement\nfake-runner: profile refused\n', rules))
|
||||
.toEqual({ detail: 'fake-runner: profile refused' })
|
||||
})
|
||||
|
||||
it('rejects zero/null exits, gate mismatches, and empty signatures', () => {
|
||||
expect(classifyRunnerFailure(0, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(null, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(1, 'fake-runner: x', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'clean output', rules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'fake-runner: x', [{ fatalSignatures: [' '] }])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the windows-acl rule is exit-gated on 127: a confined command that merely prints the signature on a non-127 exit is NOT a runner failure', () => {
|
||||
const windowsAclRules: readonly RunnerFailureRule[] = [{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]
|
||||
expect(classifyRunnerFailure(3, 'windows-acl-run: something the command printed', windowsAclRules)).toBeUndefined()
|
||||
expect(classifyRunnerFailure(127, 'windows-acl-run: missing --workspace', windowsAclRules))
|
||||
.toEqual({ detail: 'windows-acl-run: missing --workspace' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesSignature', () => {
|
||||
it('matches non-zero exits case-insensitively, never zero or signal exits', () => {
|
||||
expect(matchesSignature(1, 'Access to the path is denied.', ['access to the path'])).toBe(true)
|
||||
expect(matchesSignature(1, 'ACCESS IS DENIED.', ['access is denied'])).toBe(true)
|
||||
expect(matchesSignature(1, 'clean', ['access is denied'])).toBe(false)
|
||||
expect(matchesSignature(0, 'access is denied', ['access is denied'])).toBe(false)
|
||||
expect(matchesSignature(null, 'access is denied', ['access is denied'])).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!pwshAvailable())('SandboxPwshExecutor', () => {
|
||||
// Denial device for the POSIX classification cases: a mode-0555 directory
|
||||
// INSIDE a temp scratch tree (the same device as bash-sandbox's suites) —
|
||||
// unit tests never attempt writes outside the system temp directory. On
|
||||
// win32 there is no POSIX mode denial; the real-sandbox denial coverage
|
||||
// lives in tests/acl.e2e.ts, where the ACL runner denies scratch paths.
|
||||
const readOnlyDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-ro-'))
|
||||
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o555)
|
||||
const deniedWriteCommand = `[IO.File]::WriteAllText('${join(readOnlyDir, 'probe.txt')}', 'x')`
|
||||
|
||||
afterAll(() => {
|
||||
if (process.platform !== 'win32') chmodSync(readOnlyDir, 0o755)
|
||||
rmSync(readOnlyDir, { recursive: true, force: true })
|
||||
rmSync(spillDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const RO: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
|
||||
it('wraps the exact pwsh argv through ctx.sandbox with the per-call policy', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const result = await executor.run(executor.resolve({ command: 'echo wrapped', sandboxPolicy: RO }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]
|
||||
expect(call?.policy).toEqual(RO)
|
||||
// The confined argv is the pwsh invocation, ready for a runner prefix.
|
||||
expect(call?.argv[0]).toMatch(/pwsh(\.exe)?$/u)
|
||||
expect(call?.argv).toContain('-NonInteractive')
|
||||
expect(call?.argv.at(-1)).toContain('echo wrapped')
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('advertises the deployment default mode and stamps the deployment policy when none rides the request', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
expect(executor.sandboxMode).toBe('workspace-write')
|
||||
const result = await executor.run(executor.resolve({ command: 'echo fallback' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls[0]?.policy.mode).toBe('workspace-write')
|
||||
}, 30_000)
|
||||
|
||||
it('danger-full-access bypasses confine entirely and stamps full-access facts', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const result = await executor.run(executor.resolve({ command: 'echo full', sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' } }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
}, 30_000)
|
||||
|
||||
it('an aborted caller signal outranks runner-spawn attribution', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('caller-cancel')
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never', sandboxPolicy: RO, signal: controller.signal })))
|
||||
.rejects.toThrow('caller-cancel')
|
||||
}, 30_000)
|
||||
|
||||
// POSIX-only: the denial device is a mode-0555 scratch dir. On win32 the
|
||||
// real-sandbox denial classification is covered by tests/acl.e2e.ts
|
||||
// (the ACL runner denies scratch paths — unit tests never leave temp).
|
||||
it.skipIf(process.platform === 'win32')('classifies a failed write against the backend denial dialect', async () => {
|
||||
const { executor } = await setup()
|
||||
const result = await executor.run(executor.resolve({
|
||||
command: deniedWriteCommand,
|
||||
sandboxPolicy: RO,
|
||||
}))
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('a runner launch refusal fails closed with SANDBOX_UNAVAILABLE, never unconfined', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
}, 30_000)
|
||||
|
||||
it('a SYNCHRONOUS attributable spawn rejection in run() fails closed, an unattributable one rethrows', async () => {
|
||||
const attributable = Object.assign(new Error('sync-enoent'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
|
||||
const { executor: closed } = await setup(() => ({
|
||||
argv: ['node', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}), throwingSubprocessService(attributable))
|
||||
await expect(closed.run(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
|
||||
const foreign = Object.assign(new Error('sync-emfile'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
|
||||
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
|
||||
await expect(passthroughError.run(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.rejects.toThrow('sync-emfile')
|
||||
}, 30_000)
|
||||
|
||||
it('a SYNCHRONOUS spawn rejection in start() follows the same attribution split', async () => {
|
||||
const attributable = Object.assign(new Error('sync-enoent-start'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
|
||||
const { executor: closed } = await setup(() => ({
|
||||
argv: ['node', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}), throwingSubprocessService(attributable))
|
||||
expect(() => closed.start(closed.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.toThrow(SandboxUnavailableError)
|
||||
|
||||
const foreign = Object.assign(new Error('sync-emfile-start'), { code: 'EMFILE', syscall: 'spawn', path: 'node' })
|
||||
const { executor: passthroughError } = await setup(undefined, throwingSubprocessService(foreign))
|
||||
expect(() => passthroughError.start(passthroughError.resolve({ command: 'echo never', sandboxPolicy: RO })))
|
||||
.toThrow('sync-emfile-start')
|
||||
}, 30_000)
|
||||
|
||||
it('a runner that REFUSES at runtime (fatal signature, nonzero exit) fails closed too', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: [process.execPath, '-e', 'console.error(\'fake-runner: profile refused\'); process.exit(127)', '--'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
await expect(executor.run(executor.resolve({ command: 'echo never-runs', sandboxPolicy: RO })))
|
||||
.rejects.toThrow(SandboxUnavailableError)
|
||||
}, 30_000)
|
||||
|
||||
it('background confined runs stamp clean facts at settlement', async () => {
|
||||
const { executor } = await setup()
|
||||
const clean = executor.start(executor.resolve({ command: 'echo background-ok', sandboxPolicy: RO }))
|
||||
await clean.done
|
||||
expect(clean.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
// POSIX-only denial device (mode-0555 scratch); win32 real-sandbox denial
|
||||
// coverage lives in tests/acl.e2e.ts.
|
||||
it.skipIf(process.platform === 'win32')('background denied writes stamp denied facts at settlement', async () => {
|
||||
const { executor } = await setup()
|
||||
const denied = executor.start(executor.resolve({
|
||||
command: deniedWriteCommand,
|
||||
sandboxPolicy: RO,
|
||||
}))
|
||||
await denied.done
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
}, 30_000)
|
||||
|
||||
it('background spawn rejections settle as runnerFailed facts', async () => {
|
||||
const { executor } = await setup(() => ({
|
||||
argv: ['definitely-not-a-real-runner', '--', 'pwsh'],
|
||||
enforcement: 'full',
|
||||
denialSignatures: [],
|
||||
runnerFailureRules: [{ fatalSignatures: ['fake-runner: '] }],
|
||||
}))
|
||||
const proc = executor.start(executor.resolve({ command: 'echo never', sandboxPolicy: RO }))
|
||||
await proc.done
|
||||
expect(proc.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
|
||||
// The failure note surfaces through the read path.
|
||||
const read = proc.readOutput()
|
||||
expect(read.delta).toContain('spawn failed')
|
||||
}, 30_000)
|
||||
|
||||
it('danger-full-access background runs bypass confine and carry no facts', async () => {
|
||||
const { executor, calls } = await setup()
|
||||
const proc = executor.start(executor.resolve({
|
||||
command: 'echo full-bg',
|
||||
sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: '/ws' },
|
||||
}))
|
||||
await proc.done
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(proc.sandbox).toBeUndefined()
|
||||
}, 30_000)
|
||||
})
|
||||
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
39
packages/bash/pwsh-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/pwsh-local"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
|
||||
README.md: 9e2cd0c2ed999a11dcbffcd99a1d0cb672367905
|
||||
README.zh.md: 8a1e7ba0af36ffd891b38491075ee75283fdbed5
|
||||
README.md: d5483e5e266a5362d9a8d324d71046591fe146bc
|
||||
README.zh.md: 527543ea7130cb89e7e9776cb71adec842527e85
|
||||
|
||||
@@ -42,7 +42,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md).
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
`BashExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-bash",
|
||||
"description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/bash/tool-bash"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
@@ -25,21 +32,21 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-env": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-env": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
@@ -61,6 +68,6 @@
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash'
|
||||
|
||||
@@ -66,7 +66,7 @@ export function renderResult(
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
|
||||
* rendering (`(no new output)`) is the generic control surface's job.
|
||||
* rendering (`(no new output)`) is the generic task controller's job.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @param sandbox - settled sandbox facts, when this was a confined process.
|
||||
* @param escalationModes - escalation targets advertised by this composition.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user