From cd43108e4bfa0366efd4b17d7825fa70f63b52c9 Mon Sep 17 00:00:00 2001 From: kongche-jbw Date: Thu, 13 Aug 2026 10:50:10 +0800 Subject: [PATCH] feat(cosh-ng): [gateway] add ACP v1 foundation - Add durable contracts, storage, capability admission, and ACP runtime. - Pin Rust 1.88 and define the bilingual Phase 0-2 roadmap. - Leave installed entrypoints and live-adapter acceptance for stacked work. Signed-off-by: kongche-jbw --- .github/workflows/_rpm-build.yaml | 1 + .github/workflows/ci.yaml | 6 +- .../en/cosh-ng/architecture.md | 63 +- .../zh/cosh-ng/architecture.md | 51 +- src/cosh-ng/AGENTS.md | 6 +- src/cosh-ng/CONTRIBUTING.md | 8 +- src/cosh-ng/CONTRIBUTING_zh.md | 8 +- src/cosh-ng/Cargo.lock | 561 ++++++++++++- src/cosh-ng/Cargo.toml | 8 +- src/cosh-ng/cosh-ng.spec.in | 2 +- .../crates/cosh-gateway-contracts/Cargo.toml | 15 + .../cosh-gateway-contracts/src/capability.rs | 150 ++++ .../cosh-gateway-contracts/src/common.rs | 434 ++++++++++ .../cosh-gateway-contracts/src/error.rs | 145 ++++ .../cosh-gateway-contracts/src/external.rs | 46 ++ .../crates/cosh-gateway-contracts/src/ids.rs | 154 ++++ .../crates/cosh-gateway-contracts/src/lib.rs | 14 + .../cosh-gateway-contracts/src/runtime.rs | 242 ++++++ .../crates/cosh-gateway-contracts/src/task.rs | 465 +++++++++++ .../cosh-gateway-contracts/tests/contracts.rs | 251 ++++++ src/cosh-ng/crates/cosh-gateway/Cargo.toml | 20 + .../crates/cosh-gateway/src/capability.rs | 11 + .../cosh-gateway/src/capability/broker.rs | 377 +++++++++ .../cosh-gateway/src/capability/memory.rs | 156 ++++ .../src/capability/memory/tests.rs | 522 +++++++++++++ src/cosh-ng/crates/cosh-gateway/src/lib.rs | 7 + .../crates/cosh-gateway/src/runtime.rs | 43 + .../crates/cosh-gateway/src/runtime/acp.rs | 20 + .../cosh-gateway/src/runtime/acp/bridge.rs | 296 +++++++ .../cosh-gateway/src/runtime/acp/codec.rs | 725 +++++++++++++++++ .../cosh-gateway/src/runtime/acp/tests.rs | 583 ++++++++++++++ .../cosh-gateway/src/runtime/acp/types.rs | 378 +++++++++ .../cosh-gateway/src/runtime/bounded_io.rs | 444 +++++++++++ .../src/runtime/cosh_core_jsonl.rs | 13 + .../src/runtime/cosh_core_jsonl/codec.rs | 467 +++++++++++ .../src/runtime/cosh_core_jsonl/tests.rs | 167 ++++ .../src/runtime/cosh_core_jsonl/types.rs | 462 +++++++++++ .../cosh-gateway/src/runtime/process_group.rs | 83 ++ .../cosh-gateway/src/runtime/profile.rs | 452 +++++++++++ .../cosh-gateway/src/runtime/profile/tests.rs | 228 ++++++ .../src/runtime/session_driver.rs | 688 ++++++++++++++++ .../src/runtime/session_driver/tests.rs | 302 +++++++ .../cosh-gateway/src/runtime/supervisor.rs | 738 ++++++++++++++++++ .../src/runtime/supervisor/tests.rs | 156 ++++ .../crates/cosh-gateway/src/storage.rs | 93 +++ .../crates/cosh-gateway/src/storage/schema.rs | 219 ++++++ .../crates/cosh-gateway/src/storage/sqlite.rs | 333 ++++++++ .../cosh-gateway/src/storage/task_store.rs | 472 +++++++++++ .../src/storage/task_store/tests.rs | 407 ++++++++++ src/cosh-ng/crates/cosh-gateway/src/task.rs | 5 + .../crates/cosh-gateway/src/task/aggregate.rs | 472 +++++++++++ .../cosh-gateway/src/task/aggregate/tests.rs | 563 +++++++++++++ .../crates/cosh-platform/src/audit/query.rs | 2 +- .../cosh-platform/src/audit/retention.rs | 2 +- .../src/tools/command_risk_parser.rs | 3 +- .../docs/design/acp-v1-phase-0-2/README.md | 141 ++++ .../docs/design/acp-v1-phase-0-2/README_zh.md | 123 +++ .../acp-v1-phase-0-2/acceptance-report.md | 248 ++++++ .../acp-v1-phase-0-2/acceptance-report_zh.md | 228 ++++++ .../design/acp-v1-phase-0-2/architecture.md | 407 ++++++++++ .../acp-v1-phase-0-2/architecture_zh.md | 366 +++++++++ .../identity-correlation/acceptance.md | 152 ++++ .../identity-correlation/acceptance_zh.md | 149 ++++ .../phase-0/identity-correlation/design.md | 331 ++++++++ .../phase-0/identity-correlation/design_zh.md | 310 ++++++++ .../phase-0/protocol-contracts/acceptance.md | 165 ++++ .../protocol-contracts/acceptance_zh.md | 154 ++++ .../phase-0/protocol-contracts/design.md | 403 ++++++++++ .../phase-0/protocol-contracts/design_zh.md | 384 +++++++++ .../phase-0/storage-supervision/acceptance.md | 182 +++++ .../storage-supervision/acceptance_zh.md | 172 ++++ .../phase-0/storage-supervision/design.md | 443 +++++++++++ .../phase-0/storage-supervision/design_zh.md | 412 ++++++++++ .../phase-1/acp-mvp/acceptance.md | 133 ++++ .../phase-1/acp-mvp/acceptance_zh.md | 124 +++ .../phase-1/acp-mvp/design.md | 246 ++++++ .../phase-1/acp-mvp/design_zh.md | 216 +++++ .../phase-1/capability-broker/acceptance.md | 131 ++++ .../capability-broker/acceptance_zh.md | 126 +++ .../phase-1/capability-broker/design.md | 431 ++++++++++ .../phase-1/capability-broker/design_zh.md | 400 ++++++++++ .../phase-1/cosh-core-bridge/acceptance.md | 123 +++ .../phase-1/cosh-core-bridge/acceptance_zh.md | 119 +++ .../phase-1/cosh-core-bridge/design.md | 384 +++++++++ .../phase-1/cosh-core-bridge/design_zh.md | 360 +++++++++ .../phase-1/gateway-api/acceptance.md | 99 +++ .../phase-1/gateway-api/acceptance_zh.md | 93 +++ .../phase-1/gateway-api/design.md | 295 +++++++ .../phase-1/gateway-api/design_zh.md | 279 +++++++ .../task-execution-plane/acceptance.md | 117 +++ .../task-execution-plane/acceptance_zh.md | 114 +++ .../phase-1/task-execution-plane/design.md | 375 +++++++++ .../phase-1/task-execution-plane/design_zh.md | 354 +++++++++ .../phase-2/acp-client-bridge/acceptance.md | 129 +++ .../acp-client-bridge/acceptance_zh.md | 123 +++ .../phase-2/acp-client-bridge/design.md | 430 ++++++++++ .../phase-2/acp-client-bridge/design_zh.md | 401 ++++++++++ .../phase-2/shell-attachment/acceptance.md | 121 +++ .../phase-2/shell-attachment/acceptance_zh.md | 116 +++ .../phase-2/shell-attachment/design.md | 400 ++++++++++ .../phase-2/shell-attachment/design_zh.md | 374 +++++++++ .../phase-2/web-presentation/acceptance.md | 126 +++ .../phase-2/web-presentation/acceptance_zh.md | 121 +++ .../phase-2/web-presentation/design.md | 448 +++++++++++ .../phase-2/web-presentation/design_zh.md | 428 ++++++++++ .../acp-v1-phase-0-2/warp-comparison.md | 166 ++++ .../acp-v1-phase-0-2/warp-comparison_zh.md | 150 ++++ src/cosh-ng/rust-toolchain.toml | 2 +- 108 files changed, 25327 insertions(+), 36 deletions(-) create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/Cargo.toml create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/capability.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/common.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/error.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/external.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/ids.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/lib.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/runtime.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/src/task.rs create mode 100644 src/cosh-ng/crates/cosh-gateway-contracts/tests/contracts.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/Cargo.toml create mode 100644 src/cosh-ng/crates/cosh-gateway/src/capability.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/capability/broker.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/capability/memory.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/capability/memory/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/lib.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/acp.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/acp/bridge.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/acp/codec.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/acp/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/acp/types.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/bounded_io.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/codec.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/types.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/process_group.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/profile.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/profile/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/storage.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/storage/schema.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/storage/sqlite.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/storage/task_store.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/storage/task_store/tests.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/task.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/task/aggregate.rs create mode 100644 src/cosh-ng/crates/cosh-gateway/src/task/aggregate/tests.rs create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/README.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/README_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design_zh.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison.md create mode 100644 src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison_zh.md diff --git a/.github/workflows/_rpm-build.yaml b/.github/workflows/_rpm-build.yaml index 2ab057b7c4..fc95802c54 100644 --- a/.github/workflows/_rpm-build.yaml +++ b/.github/workflows/_rpm-build.yaml @@ -60,6 +60,7 @@ jobs: ;; cosh-ng) dnf install -y rust cargo openssl-devel pkgconfig + rustc --version | awk '{ split($2, v, "."); if (v[1] != 1 || v[2] < 88) exit 1 }' ;; agent-sec-core) dnf_install clang llvm openssl-devel libseccomp-devel bubblewrap python3-pip systemd-rpm-macros diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d785588347..4ba9b1b819 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1163,7 +1163,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 - uses: Swatinem/rust-cache@v2 with: @@ -1195,7 +1195,7 @@ jobs: with: python-version: '3.11' - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 with: components: 'rustfmt, clippy' @@ -1239,7 +1239,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.88.0 - uses: Swatinem/rust-cache@v2 with: diff --git a/docs/developer-guide/en/cosh-ng/architecture.md b/docs/developer-guide/en/cosh-ng/architecture.md index 51fef49301..29cc9aa975 100644 --- a/docs/developer-guide/en/cosh-ng/architecture.md +++ b/docs/developer-guide/en/cosh-ng/architecture.md @@ -2,10 +2,12 @@ [中文版](../../zh/cosh-ng/architecture.md) -cosh-ng separates the interactive terminal, Agent runtime, and deterministic OS -API so each boundary can be tested and integrated independently. +cosh-ng separates the interactive terminal, Agent runtime, deterministic OS +API, and an emerging Gateway control plane so each boundary can be tested and +integrated independently. The Gateway material described below is a partial +candidate-worktree foundation, not an upstream production service. -## System view +## Upstream system view ```text bash/zsh <--- cosh-shell @@ -27,6 +29,46 @@ owns a long-lived cosh-core child at runtime. The stdin/stdout protocol between them must remain backward-aware because either side can fail or restart independently. +The pinned upstream baseline for the Gateway plan is +`fa0c8369d300d90a6470965dc564e20b09487eb7`. It contains the five crates and +runtime path above, but no `cosh-gateway` or `cosh-gateway-contracts` crate. + +## Candidate Gateway foundation + +The shared candidate worktree based on that baseline adds two library crates: + +```text +cosh-gateway-contracts --> TaskAggregate --> SQLite Task/event/receipt/Outbox transaction + | + +---------------> Capability Broker slice (in-memory, targeted tests) + +cosh-gateway ----------> RuntimeSupervisor --> private COSH JSONL v1 codec + `-------> official ACP wire-v1 codec/bridge + + bounded session driver + + fixed installed-adapter profiles + +future CoshCoreBridge --> contracts public mapping + supervisor + codec + +Gateway daemon/API, CoshCoreBridge, installed ACP entrypoint, +complete ACP domain/governance mapping, Shell attachment, and Web presentation +are not implemented. +``` + +The Task reducer and SQLite store are local control-plane foundations. The +Runtime supervisor owns a directly launched child process group, bounded +stdout/stderr, escalation/reap, and one process terminal observation. Its +cosh-core codec speaks the existing **private COSH control protocol v1**; it is +not ACP and is not yet mapped to public Runtime events. + +No executable Gateway entry point or authenticated Unix/network API exists. +The current Shell path is unchanged: `cosh-shell` still owns its native PTY and +compatibility cosh-core process. The candidate pins official ACP Rust SDK 2.0.0, +raises the component baseline to Rust 1.88, and adds a supervised stable-v1 +stdio slice plus built-in profiles for installed `codex-acp` and +`claude-agent-acp`. There is no package-runner or network bootstrap path. The +library still lacks an installed entrypoint, a session driver with independent +cancel, a production permission proxy, and real-adapter conformance evidence. + ## Crate responsibilities | Crate | Binary | Owns | Must not own | @@ -36,6 +78,8 @@ independently. | `cosh-cli` | `cosh-cli` | Clap commands, JSON envelope, exit status | Distro-specific branching outside platform adapters | | `cosh-core` | `cosh-core` | Providers, tool loop, hooks, Skills, MCP, extensions, registry, sessions, and compaction | Terminal ownership or foreground PTY interaction | | `cosh-shell` | `cosh-shell` | PTY host, input routing, cards, approvals, evidence, UI, core process lifecycle | Provider implementation or direct OS API abstraction | +| `cosh-gateway-contracts` (candidate) | — | Side-effect-free Task, Runtime, Capability, identity, header, and error contracts with bounded leaf strings/digests | Storage, process ownership, transport, provider, OS execution, or aggregate admission limits not yet implemented | +| `cosh-gateway` (candidate) | — | Partial Task reducer/SQLite store, Runtime supervision/private core codec, ACP v1 codec/bridge and fixed installed-adapter profiles, and Capability integration slice | Shell PTY, installed Gateway/ACP entrypoints, provider/ACP wire types as domain contracts, OS effects outside the Broker, or ungoverned ACP callbacks | ## Interactive data flow @@ -108,5 +152,18 @@ after structural changes. - Tool auto-approval fails closed. Raw command substring matching is not a security boundary. +## Gateway and ACP delivery boundary + +The candidate libraries do not form a durable production Gateway. They still +lack the Gateway API/daemon, Task coordinator and lease/recovery loop, complete +Capability enforcement, integrated CoshCore Bridge, installed ACP Runtime +entrypoint, production permission UI/evidence, real-adapter evidence, +Shell attachment, and Web/channel presentation. The +[ACP v1 Phase 0-2 planning set](../../../../src/cosh-ng/docs/design/acp-v1-phase-0-2/README.md) +separates the pinned upstream baseline from candidate implementation evidence +and defines the remaining module boundaries, Warp comparison, delivery +sequence, and acceptance gates. Overall Phase 0-2 status remains **NOT +ACCEPTED**. + Continue with [Developing cosh-ng](getting-started.md), [IPC protocols](ipc-protocol.md), and [Testing](testing.md). diff --git a/docs/developer-guide/zh/cosh-ng/architecture.md b/docs/developer-guide/zh/cosh-ng/architecture.md index ef2bc9a499..ccc81f3a0c 100644 --- a/docs/developer-guide/zh/cosh-ng/architecture.md +++ b/docs/developer-guide/zh/cosh-ng/architecture.md @@ -2,9 +2,11 @@ [English](../../en/cosh-ng/architecture.md) -cosh-ng 将交互式终端、Agent 运行时和确定性的操作系统 API 分开。每个边界都能独立测试,也可以由其他程序单独集成。 +cosh-ng 将交互式终端、Agent 运行时、确定性的操作系统 API 和逐步形成的 Gateway control plane +分开。每个边界都能独立测试,也可以由其他程序单独集成。下文 Gateway 内容是候选工作树中的局部基础, +不是上游 production service。 -## 系统视图 +## 上游系统视图 ```text bash/zsh <--- cosh-shell @@ -22,6 +24,40 @@ caller ---> cosh-cli ---> cosh-platform ---> cosh-types 安装后的 `cosh` 启动器通常执行 `cosh-shell raw cosh-core`。`cosh-shell` 编译时不依赖工作空间中的其他 crate,运行时则维护一个长时间存活的 cosh-core 子进程。两端都可能独立失败或重启,因此 stdin/stdout 协议需要保持向后兼容。 +Gateway 规划固定的上游基线是 `fa0c8369d300d90a6470965dc564e20b09487eb7`。该基线包含上图五个 +crate 与 runtime path,但没有 `cosh-gateway` 或 `cosh-gateway-contracts` crate。 + +## 候选 Gateway 基础 + +基于该基线的共享候选工作树增加两个 library crate: + +```text +cosh-gateway-contracts --> TaskAggregate --> SQLite Task/event/receipt/Outbox transaction + | + +---------------> Capability Broker slice(in-memory,targeted test) + +cosh-gateway ----------> RuntimeSupervisor --> private COSH JSONL v1 codec + `-------> official ACP wire-v1 codec/Bridge + + 固定 installed-adapter profile + +未来 CoshCoreBridge --> contract public mapping + supervisor + codec + +Gateway daemon/API、CoshCoreBridge、已安装 ACP entrypoint、完整 ACP +domain/governance mapping、Shell Attachment 与 Web presentation 均未实现。 +``` + +Task reducer 与 SQLite store 是 local control-plane 基础。Runtime supervisor 独占一个 direct child +process group、bounded stdout/stderr、escalation/reap 与一次 process terminal observation。它的 +cosh-core codec 使用现有 **private COSH control protocol v1**,不是 ACP,也尚未映射为 public +Runtime event。 + +当前不存在 executable Gateway entry point 或 authenticated Unix/network API。Shell path 没有改变, +`cosh-shell` 仍拥有 native PTY 与 compatibility cosh-core process。候选树准确固定官方 ACP Rust SDK +2.0.0,把组件 baseline 提升到 Rust 1.88,并增加 supervised stable-v1 stdio slice 以及已安装 +`codex-acp`/`claude-agent-acp` 的内置 profile。这里没有 package runner 或 network bootstrap +路径。Library 已有支持独立 cancel 的有界 Session Driver,仍缺已安装 entrypoint、production +Permission UI/evidence 与 real-adapter conformance 证据。 + ## Crate 职责 | Crate | 二进制 | 拥有 | 不应拥有 | @@ -31,6 +67,8 @@ caller ---> cosh-cli ---> cosh-platform ---> cosh-types | `cosh-cli` | `cosh-cli` | Clap 命令、JSON 响应、退出状态 | 平台适配器之外的发行版分支 | | `cosh-core` | `cosh-core` | 模型服务、工具循环、Hooks、Skills、MCP、Extensions、注册表、会话和压缩 | 终端控制或前台 PTY 交互 | | `cosh-shell` | `cosh-shell` | PTY 宿主、输入路由、卡片、审批、终端证据、界面、core 进程生命周期 | 模型服务实现或直接抽象操作系统 API | +| `cosh-gateway-contracts`(候选) | 无 | 无副作用的 Task、Runtime、Capability、identity、header 与 error contract,leaf string/digest 有界 | Storage、process ownership、transport、provider、OS execution 或尚未实现的 aggregate admission limit | +| `cosh-gateway`(候选) | 无 | 局部 Task reducer/SQLite store、Runtime supervision/private core codec、ACP v1 codec/Bridge 与固定 installed-adapter profile、Capability integration slice | Shell PTY、已安装 Gateway/ACP entrypoint、把 provider/ACP wire type 当作 domain contract、绕过 Broker 的 OS effect 或未治理的 ACP callback | ## 交互数据流 @@ -85,4 +123,13 @@ Clap command - Linux 包路由可使用 `ID_LIKE` 中第一个可识别家族,但 typed 和 JSON 输出仍保留发行版的真实 `ID`。 - 工具自动审批在无法判断时拒绝执行。直接匹配原始命令子串不能充当安全边界。 +## Gateway 与 ACP 交付边界 + +候选 library 尚未组成持久 production Gateway,仍缺 Gateway API/daemon、Task coordinator 与 +lease/recovery loop、完整 Capability enforcement、集成 CoshCore Bridge、已安装 ACP Runtime +entrypoint/Session Driver、production Permission Proxy、real-adapter 证据、Shell Attachment 与 +Web/channel presentation。[ACP v1 Phase 0-2 规划集](../../../../src/cosh-ng/docs/design/acp-v1-phase-0-2/README_zh.md) +区分固定的上游基线与候选实现证据,并定义剩余模块边界、Warp 对比、交付顺序与验收 Gate。 +Phase 0-2 总体状态仍为 **NOT ACCEPTED**。 + 继续阅读[开发 cosh-ng](getting-started.md)、[IPC 协议](ipc-protocol.md)和[测试](testing.md)。 diff --git a/src/cosh-ng/AGENTS.md b/src/cosh-ng/AGENTS.md index a452a42aa5..1f238df76c 100644 --- a/src/cosh-ng/AGENTS.md +++ b/src/cosh-ng/AGENTS.md @@ -58,17 +58,19 @@ crates/cosh-shell/scripts/check-layout.sh 该脚本必须保持通过;新增或迁移代码不能增加新的 violation group。脚本中的 registered debt 只表示迁移债务被 inventory 追踪,不代表最终验收已完成。 -Prerequisites: Linux (or macOS for limited functionality), Rust 1.74+. pkg/svc commands need root/sudo. Checkpoint commands need a running ws-ckpt daemon. +Prerequisites: Linux (or macOS for limited functionality), Rust 1.88+. pkg/svc commands need root/sudo. Checkpoint commands need a running ws-ckpt daemon. ## Architecture -5-crate workspace. Dependency direction: `cosh-cli` / `cosh-core` → `cosh-platform` → `cosh-types`; `cosh-shell` is standalone (no internal crate deps). +7-crate workspace. Dependency direction: `cosh-cli` / `cosh-core` → `cosh-platform` → `cosh-types`; `cosh-shell` is standalone (no internal crate deps). `cosh-gateway` depends only on the side-effect-free `cosh-gateway-contracts` leaf among internal crates. - **cosh-types**: Pure types, zero side effects. Defines `CoshResponse` envelope, `CoshError` (with error codes, recoverable flag, hint), and ws-ckpt IPC protocol types. - **cosh-platform**: Platform abstraction layer. Distro detection from `/etc/os-release`, package manager routing (dnf/apt/zypper/brew), systemd service adapter, ws-ckpt daemon Unix socket IPC client. - **cosh-cli**: CLI entry point (binary: `cosh-cli`). 4 command domains: `pkg`, `svc`, `checkpoint`, `audit`. All output is JSON via `CoshResponse`. Uses clap derive for argument parsing. - **cosh-core**: Unified agent core (binary: `cosh-core`). Headless JSONL backend + LLM provider integration (OpenAI-compat, SysOM/Aliyun). Includes hooks, tools, skills, extensions, and config management. Interactive TUI mode is declared but not yet implemented. - **cosh-shell**: AI-augmented interactive shell (binary: `cosh-shell`). PTY wrapper over bash/zsh with OSC marker-based command boundary detection, streaming AI analysis (Claude/Qwen adapters), inline card rendering (ratatui), tool approval control protocol. +- **cosh-gateway-contracts**: Side-effect-free Gateway Task, Runtime, Capability, identity, and error contracts. It must not own storage, processes, transports, providers, or OS execution. +- **cosh-gateway**: Gateway control-plane library foundations: Task reduction and storage, Runtime supervision, private core transport, ACP v1 codec/bridge, bounded session driver, fixed installed-adapter profiles, and Capability admission. It does not currently provide a daemon, installed ACP entrypoint, production permission UI/evidence, or real-adapter conformance evidence. ### cosh-shell Code Organization diff --git a/src/cosh-ng/CONTRIBUTING.md b/src/cosh-ng/CONTRIBUTING.md index 8b90124c70..988df0fac4 100644 --- a/src/cosh-ng/CONTRIBUTING.md +++ b/src/cosh-ng/CONTRIBUTING.md @@ -7,7 +7,7 @@ | Requirement | Version | |-------------|---------| | Rust toolchain | stable (managed by `rust-toolchain.toml`) | -| Minimum Rust version | 1.74 | +| Minimum Rust version | 1.88 | | Components | rustfmt + clippy | | Supported platforms | Linux (full); macOS (limited functionality) | @@ -19,7 +19,7 @@ rustup show # Confirm toolchain is ready ## Build ```bash -# Full build (all 5 crates) +# Full build (all workspace crates) cargo build --workspace # Release build @@ -72,7 +72,9 @@ cosh-ng/ ├── cosh-platform/ # Platform abstraction (distro detection, backend routing) ├── cosh-cli/ # CLI entry ├── cosh-core/ # Agent core - └── cosh-shell/ # Interactive terminal + ├── cosh-shell/ # Interactive terminal + ├── cosh-gateway-contracts/ # Side-effect-free Gateway contracts + └── cosh-gateway/ # Gateway control-plane library foundations ``` ## Dependency Management diff --git a/src/cosh-ng/CONTRIBUTING_zh.md b/src/cosh-ng/CONTRIBUTING_zh.md index 1297692c59..fc36dad09c 100644 --- a/src/cosh-ng/CONTRIBUTING_zh.md +++ b/src/cosh-ng/CONTRIBUTING_zh.md @@ -7,7 +7,7 @@ | 要求 | 版本 | |------|------| | Rust toolchain | stable(`rust-toolchain.toml` 管理) | -| Rust 最低版本 | 1.74 | +| Rust 最低版本 | 1.88 | | 组件 | rustfmt + clippy | | 支持平台 | Linux(完整功能);macOS(功能受限) | @@ -19,7 +19,7 @@ rustup show # 确认工具链已就绪 ## 构建 ```bash -# 完整构建(所有 5 个 crate) +# 完整构建(所有 workspace crate) cargo build --workspace # 发布构建 @@ -69,7 +69,9 @@ cosh-ng/ ├── cosh-platform/ # 平台抽象(发行版检测、后端路由) ├── cosh-cli/ # CLI 入口 ├── cosh-core/ # Agent 核心 - └── cosh-shell/ # 交互终端 + ├── cosh-shell/ # 交互终端 + ├── cosh-gateway-contracts/ # 无副作用的 Gateway contract + └── cosh-gateway/ # Gateway control plane library 基础 ``` ## 依赖管理 diff --git a/src/cosh-ng/Cargo.lock b/src/cosh-ng/Cargo.lock index cd93bb159c..03060837d8 100644 --- a/src/cosh-ng/Cargo.lock +++ b/src/cosh-ng/Cargo.lock @@ -37,6 +37,68 @@ dependencies = [ "subtle", ] +[[package]] +name = "agent-client-protocol" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d87bc7769eba641753ba5dc52f73ec3765d51022c6753bf040967125ddc86a8" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io 2.6.0", + "async-process 2.5.0", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "rustix 1.1.4", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abd4080f51e4f24f5042beb7fb7a66ede29a2dc1c2582c329532e1c27264ddc" +dependencies = [ + "quote", + "syn 3.0.3", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5c231915b4ab578c722eca2d1bd7df4d300bfd6cac3b8e9f0d1e3ddc95b187c" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -240,6 +302,24 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -373,6 +453,15 @@ dependencies = [ "piper", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.13.0" @@ -538,6 +627,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -623,6 +721,31 @@ dependencies = [ "which", ] +[[package]] +name = "cosh-gateway" +version = "0.15.0" +dependencies = [ + "agent-client-protocol", + "cosh-gateway-contracts", + "nix 0.29.0", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "wait-timeout", +] + +[[package]] +name = "cosh-gateway-contracts" +version = "0.15.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "cosh-platform" version = "0.15.0" @@ -795,11 +918,45 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derivative" @@ -812,6 +969,29 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -855,6 +1035,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -951,6 +1137,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -982,6 +1180,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -1062,6 +1266,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite 2.6.1", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1228,13 +1445,28 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1252,6 +1484,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1574,6 +1815,17 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1685,6 +1937,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.98" @@ -1758,6 +2063,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -2165,6 +2481,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2230,6 +2566,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2348,8 +2699,8 @@ dependencies = [ "itertools", "lru", "paste", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2375,6 +2726,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex" version = "1.12.4" @@ -2462,6 +2833,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.37.28" @@ -2574,6 +2974,43 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2683,12 +3120,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -2728,13 +3177,46 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -2772,6 +3254,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2865,7 +3353,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2881,6 +3378,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2915,6 +3424,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3058,6 +3578,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -3157,7 +3692,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "winnow 0.5.40", ] @@ -3168,7 +3703,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -3564,7 +4099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -3590,7 +4125,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3932,7 +4467,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -3963,7 +4498,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3982,7 +4517,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -4042,7 +4577,7 @@ dependencies = [ "async-fs", "async-io 1.13.0", "async-lock 2.8.0", - "async-process", + "async-process 1.8.1", "async-recursion", "async-task", "async-trait", diff --git a/src/cosh-ng/Cargo.toml b/src/cosh-ng/Cargo.toml index b72a2bc59c..37bfc2030e 100644 --- a/src/cosh-ng/Cargo.toml +++ b/src/cosh-ng/Cargo.toml @@ -5,17 +5,20 @@ members = [ "crates/cosh-cli", "crates/cosh-core", "crates/cosh-shell", + "crates/cosh-gateway-contracts", + "crates/cosh-gateway", ] resolver = "2" [workspace.package] version = "0.15.0" edition = "2021" -rust-version = "1.74" +rust-version = "1.88" license = "Apache-2.0" repository = "https://github.com/alibaba/anolisa" [workspace.dependencies] +agent-client-protocol = "=2.0.0" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", default-features = false } @@ -39,6 +42,9 @@ tracing-appender = "0.2" signal-hook = "0.3" uuid = { version = "1", features = ["v4"] } wait-timeout = "0.2" +rusqlite = { version = "0.32", features = ["bundled"] } +thiserror = "2" +tempfile = "3" [profile.release] opt-level = 3 diff --git a/src/cosh-ng/cosh-ng.spec.in b/src/cosh-ng/cosh-ng.spec.in index dedf381217..acdf8c6f49 100644 --- a/src/cosh-ng/cosh-ng.spec.in +++ b/src/cosh-ng/cosh-ng.spec.in @@ -10,7 +10,7 @@ License: Apache-2.0 URL: https://github.com/alibaba/anolisa Source0: %{name}-%{version}.tar.gz -BuildRequires: rust >= 1.74 +BuildRequires: rust >= 1.88 BuildRequires: cargo BuildRequires: gcc BuildRequires: openssl-devel diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/Cargo.toml b/src/cosh-ng/crates/cosh-gateway-contracts/Cargo.toml new file mode 100644 index 0000000000..26a2e8ec80 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cosh-gateway-contracts" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true +uuid.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/capability.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/capability.rs new file mode 100644 index 0000000000..2758cd6f39 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/capability.rs @@ -0,0 +1,150 @@ +//! Capability requests, approval decisions, and execution permits. + +use serde::{Deserialize, Serialize}; + +use crate::{ + common::{ActorRef, BoundedName, BoundedText, Digest, TargetRef}, + ids::{ActorId, ApprovalId, ExecutionId, PermitId, RequestId, RunId, TaskId}, +}; + +/// Normalized operation proposed by an Agent Runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationDescriptor { + /// Operation namespace, such as process, file, package, or service. + pub namespace: BoundedName, + /// Operation name within the namespace. + pub name: BoundedName, + /// Digest of normalized operation arguments. + pub arguments_digest: Digest, +} + +/// Requested policy scope independent from a provider permission shape. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityScope { + /// Resource category governed by policy. + pub resource: BoundedName, + /// Access mode requested for the resource. + pub access: BoundedName, +} + +/// Domain request evaluated by the capability broker before a side effect. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequest { + /// COSH-owned request identity. + pub request_id: RequestId, + /// Task owning the request. + pub task_id: TaskId, + /// Run that observed the requested operation. + pub run_id: RunId, + /// Authenticated actor on whose behalf the Runtime acts. + pub actor: ActorRef, + /// Target environment affected by the operation. + pub target: TargetRef, + /// Normalized operation proposed by the Runtime. + pub operation: OperationDescriptor, + /// Digest of the complete canonical operation, including its namespace, + /// name, and normalized arguments. A trusted ingress canonicalizes and + /// hashes the operation before constructing this request. + pub operation_digest: Digest, + /// Policy scope requested by the operation. + pub requested_scope: CapabilityScope, + /// Digest of the complete original Runtime input. + pub input_digest: Digest, + /// Millisecond timestamp after which the request must fail closed. + pub expires_at_ms: u64, +} + +/// Durable approval request produced by capability policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApprovalRequest { + /// COSH-owned approval identity. + pub approval_id: ApprovalId, + /// Capability request awaiting approval. + pub request_id: RequestId, + /// Task owning the decision. + pub task_id: TaskId, + /// Run paused for the decision. + pub run_id: RunId, + /// Redacted human-readable explanation. + pub summary: BoundedText, + /// Millisecond timestamp after which the approval is stale. + pub expires_at_ms: u64, +} + +/// Human or policy response to an approval request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + /// Approve the requested scope once policy issues a permit. + Approve, + /// Deny the requested scope. + Deny, +} + +/// Stable policy denial classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DenialCode { + /// Requested capability is prohibited by policy. + PolicyDenied, + /// Actor lacks access to the Task or target. + Unauthorized, + /// Approval was denied or expired. + ApprovalDenied, + /// Request is stale or no longer matches active state. + StaleRequest, +} + +/// Single policy authorization bound to one normalized operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionPermit { + /// COSH-owned permit identity. + pub permit_id: PermitId, + /// Capability request authorized by this permit. + pub request_id: RequestId, + /// Actor authorized to use the permit. + pub actor_id: ActorId, + /// Optional approval that authorized the request. + pub approval_id: Option, + /// Task owning the authorization. + pub task_id: TaskId, + /// Run owning the authorization. + pub run_id: RunId, + /// Governed execution attempt authorized by the permit. + pub execution_id: ExecutionId, + /// Target bound to the permit. + pub target: TargetRef, + /// Digest of the normalized operation bound to the permit. + pub operation_digest: Digest, + /// Digest of the complete Runtime input admitted by policy. + pub input_digest: Digest, + /// Policy revision that produced the authorization decision. + pub policy_revision: u64, + /// Millisecond timestamp after which the permit is invalid. + pub valid_until_ms: u64, + /// Whether successful admission consumes the permit. + pub single_use: bool, +} + +/// Result of evaluating a capability request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum CapabilityDecision { + /// Policy issued a permit that may authorize execution. + Permit { + /// Permit bound to the request and operation. + permit: ExecutionPermit, + }, + /// A durable approval must be resolved before a permit can be issued. + RequireApproval { + /// Approval request presented to an authorized actor. + approval: ApprovalRequest, + }, + /// Policy denied the request without issuing a permit. + Deny { + /// Stable reason for denial. + code: DenialCode, + /// Redacted human-readable explanation. + safe_message: BoundedText, + }, +} diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/common.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/common.rs new file mode 100644 index 0000000000..02e2863066 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/common.rs @@ -0,0 +1,434 @@ +//! Shared bounded values, headers, actors, and runtime context. + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +use crate::{ + external::ExternalRef, + ids::{ + ActorId, AgentSessionId, ApprovalId, ExecutionId, InstallationId, MessageId, PermitId, + RunId, RuntimeBindingId, RuntimeInstanceId, TaskId, + }, +}; + +/// Maximum UTF-8 byte length of user-facing contract text. +pub const MAX_TEXT_BYTES: usize = 4096; +/// Maximum UTF-8 byte length of names used for authorities and operations. +pub const MAX_NAME_BYTES: usize = 128; +/// Maximum UTF-8 byte length of opaque external values. +pub const MAX_OPAQUE_BYTES: usize = 1024; +/// Maximum UTF-8 byte length of an idempotency key. +pub const MAX_IDEMPOTENCY_KEY_BYTES: usize = 256; +/// Current COSH Gateway domain schema version. +pub const CONTRACT_SCHEMA_VERSION: u16 = 1; + +/// Failure returned when a bounded string violates its construction contract. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BoundedStringError { + /// Empty values do not carry usable contract meaning. + #[error("value must not be empty")] + Empty, + /// The UTF-8 representation exceeds the type-specific byte limit. + #[error("value exceeds the {max_bytes}-byte limit")] + TooLong { + /// Maximum accepted UTF-8 byte count. + max_bytes: usize, + }, + /// NUL bytes are forbidden at transport and operating-system boundaries. + #[error("value must not contain a NUL character")] + ContainsNul, +} + +fn validate_bounded(value: &str, max_bytes: usize) -> Result<(), BoundedStringError> { + if value.is_empty() { + return Err(BoundedStringError::Empty); + } + if value.len() > max_bytes { + return Err(BoundedStringError::TooLong { max_bytes }); + } + if value.contains('\0') { + return Err(BoundedStringError::ContainsNul); + } + Ok(()) +} + +macro_rules! bounded_string { + ($name:ident, $max:ident, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct $name(String); + + impl $name { + /// Constructs a validated bounded value. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_bounded(&value, $max)?; + Ok(Self(value)) + } + + /// Returns the validated text value. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } + } + }; +} + +bounded_string!( + BoundedText, + MAX_TEXT_BYTES, + "User-facing text whose serialized size is bounded." +); +bounded_string!( + BoundedName, + MAX_NAME_BYTES, + "A bounded authority, operation, runtime, or profile name." +); +bounded_string!( + BoundedOpaque, + MAX_OPAQUE_BYTES, + "An opaque external value with a strict serialized-size limit." +); +bounded_string!( + IdempotencyKey, + MAX_IDEMPOTENCY_KEY_BYTES, + "A caller-scoped key used to replay command admission safely." +); + +/// Error returned when a digest is not canonical lowercase SHA-256 text. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("digest must contain exactly 64 lowercase hexadecimal characters")] +pub struct DigestError; + +/// Canonical lowercase hexadecimal representation of a SHA-256 digest. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Digest(String); + +impl Digest { + /// Parses a lowercase 64-character SHA-256 digest. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(DigestError); + } + Ok(Self(value)) + } + + /// Returns the canonical hexadecimal representation. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Serialize for Digest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for Digest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +/// Stable schema discriminator for a contract envelope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ContractSchema { + /// Gateway ingress command schema. + #[serde(rename = "cosh.gateway.command")] + GatewayCommand, + /// Durable Task lifecycle event schema. + #[serde(rename = "cosh.task.event")] + TaskEvent, + /// Neutral Agent Runtime command schema. + #[serde(rename = "cosh.runtime.command")] + RuntimeCommand, + /// Neutral Agent Runtime event schema. + #[serde(rename = "cosh.runtime.event")] + RuntimeEvent, +} + +/// Failure returned when an envelope declares an unsupported schema version. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("unsupported contract schema version {actual}; expected {expected}")] +pub struct SchemaVersionError { + /// Version accepted by this crate. + pub expected: u16, + /// Version declared by the envelope. + pub actual: u16, +} + +/// Failure returned when an envelope carries another contract schema. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("envelope schema {actual:?} does not match expected schema {expected:?}")] +pub struct EnvelopeSchemaError { + /// Schema required by the envelope type. + pub expected: ContractSchema, + /// Schema declared in the decoded header. + pub actual: ContractSchema, +} + +/// Metadata common to every Gateway domain envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ContractHeader { + /// Domain schema carried by the envelope. + pub schema: ContractSchema, + /// Version of the domain schema, independent from ACP and Core versions. + pub schema_version: u16, + /// Unique identity of this command or event. + pub message_id: MessageId, + /// Milliseconds since the Unix epoch recorded by the producer. + pub occurred_at_ms: u64, + /// Lifecycle identities propagated with the message. + pub correlation: Correlation, +} + +impl ContractHeader { + /// Creates a header at the current supported domain schema version. + #[must_use] + pub fn new( + schema: ContractSchema, + message_id: MessageId, + occurred_at_ms: u64, + correlation: Correlation, + ) -> Self { + Self { + schema, + schema_version: CONTRACT_SCHEMA_VERSION, + message_id, + occurred_at_ms, + correlation, + } + } + + /// Rejects versions that this crate cannot interpret safely. + pub fn validate_version(&self) -> Result<(), SchemaVersionError> { + if self.schema_version == CONTRACT_SCHEMA_VERSION { + Ok(()) + } else { + Err(SchemaVersionError { + expected: CONTRACT_SCHEMA_VERSION, + actual: self.schema_version, + }) + } + } + + /// Rejects a header used with a different envelope type. + pub fn validate_schema(&self, expected: ContractSchema) -> Result<(), EnvelopeSchemaError> { + if self.schema == expected { + Ok(()) + } else { + Err(EnvelopeSchemaError { + expected, + actual: self.schema, + }) + } + } +} + +impl<'de> Deserialize<'de> for ContractHeader { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireHeader { + schema: ContractSchema, + schema_version: u16, + message_id: MessageId, + occurred_at_ms: u64, + correlation: Correlation, + } + + let wire = WireHeader::deserialize(deserializer)?; + let header = Self { + schema: wire.schema, + schema_version: wire.schema_version, + message_id: wire.message_id, + occurred_at_ms: wire.occurred_at_ms, + correlation: wire.correlation, + }; + header.validate_version().map_err(de::Error::custom)?; + Ok(header) + } +} + +/// Internal identities propagated across ingress, Task, Runtime, and execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Correlation { + /// Gateway installation that allocated the identities. + pub installation_id: InstallationId, + /// Authenticated actor, when resolution has completed. + pub actor_id: Option, + /// Durable Task owning the lifecycle. + pub task_id: Option, + /// Current Task execution attempt. + pub run_id: Option, + /// COSH-owned logical Agent session. + pub agent_session_id: Option, + /// Fenced Runtime binding producing the message. + pub runtime_binding_id: Option, + /// Approval relevant to this message. + pub approval_id: Option, + /// Permit relevant to this message. + pub permit_id: Option, + /// Governed execution relevant to this message. + pub execution_id: Option, + /// Direct accepted message that caused this message. + pub causation_message_id: Option, +} + +impl Correlation { + /// Starts an empty lifecycle correlation for one installation. + #[must_use] + pub fn new(installation_id: InstallationId) -> Self { + Self { + installation_id, + actor_id: None, + task_id: None, + run_id: None, + agent_session_id: None, + runtime_binding_id: None, + approval_id: None, + permit_id: None, + execution_id: None, + causation_message_id: None, + } + } +} + +/// Source category of an authenticated actor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActorKind { + /// Interactive human principal. + Human, + /// Locally configured automation principal. + Automation, + /// Operating-system service principal. + Service, +} + +/// Authentication strength established by an ingress adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthAssurance { + /// Local operating-system identity was verified. + LocalOs, + /// A channel or web identity assertion was verified. + RemoteVerified, + /// A configured automation credential was verified. + AutomationCredential, +} + +/// Authenticated actor identity supplied by an ingress identity resolver. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActorRef { + /// COSH-owned actor identity. + pub actor_id: ActorId, + /// Actor source category. + pub actor_kind: ActorKind, + /// Bounded identity issuer name. + pub issuer: BoundedName, + /// Assurance established by the adapter. + pub assurance: AuthAssurance, +} + +/// Opaque operating-system or remote environment selected for a Task. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TargetRef { + /// Target provider or environment kind. + pub kind: BoundedName, + /// Authority that owns the target namespace. + pub authority: BoundedName, + /// Opaque target identifier within the authority. + pub identifier: BoundedOpaque, +} + +/// Workspace supplied to a newly opened Agent session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRef { + /// Digest of the canonical workspace scope. + pub scope_digest: Digest, + /// Optional safe display label. + pub display_name: Option, +} + +/// Runtime choice requested by a Task command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeSelector { + /// Runtime adapter kind, such as an ACP or Core bridge. + pub runtime: BoundedName, + /// Optional configured runtime profile. + pub profile: Option, +} + +/// Fenced binding between a Task Run and an external Agent session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeBindingRef { + /// COSH binding identity. + pub binding_id: RuntimeBindingId, + /// Task owning the binding. + pub task_id: TaskId, + /// Run owning the binding. + pub run_id: RunId, + /// COSH logical Agent session. + pub agent_session_id: AgentSessionId, + /// Supervised child process identity. + pub runtime_instance_id: RuntimeInstanceId, + /// Process generation used to reject stale output. + pub runtime_generation: u64, + /// Scoped provider or ACP session reference. + pub external_session: ExternalRef, +} + +/// Content exchanged with an Agent Runtime without transport-specific types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ContentPart { + /// Bounded UTF-8 text. + Text { + /// Text content. + text: BoundedText, + }, + /// Link to a resource resolved outside the contract layer. + ResourceLink { + /// Opaque bounded resource locator. + uri: BoundedOpaque, + /// Optional safe display label. + label: Option, + }, +} diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/error.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/error.rs new file mode 100644 index 0000000000..4990bc09c6 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/error.rs @@ -0,0 +1,145 @@ +//! Bounded and machine-readable contract errors. + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +use crate::common::{BoundedOpaque, BoundedStringError, BoundedText}; + +/// Maximum UTF-8 byte length of a stable machine-readable error code. +pub const MAX_ERROR_CODE_BYTES: usize = 64; + +/// Failure returned when an error code is empty, oversized, or unstable text. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ErrorCodeError { + /// An error code must identify a concrete failure. + #[error("error code must not be empty")] + Empty, + /// Codes are capped to keep transport and storage records predictable. + #[error("error code exceeds the {MAX_ERROR_CODE_BYTES}-byte limit")] + TooLong, + /// Codes use lowercase ASCII snake-case for cross-language stability. + #[error("error code must use lowercase ASCII letters, digits, and underscores")] + InvalidCharacter, +} + +/// Stable machine-readable error code. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ErrorCode(String); + +impl ErrorCode { + /// Parses a stable lowercase snake-case error code. + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ErrorCodeError::Empty); + } + if value.len() > MAX_ERROR_CODE_BYTES { + return Err(ErrorCodeError::TooLong); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(ErrorCodeError::InvalidCharacter); + } + Ok(Self(value)) + } + + /// Returns the stable machine-readable code. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Serialize for ErrorCode { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ErrorCode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +/// Stable category used by transports and retry policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCategory { + /// Input is malformed or violates a contract precondition. + InvalidRequest, + /// State or idempotency preconditions conflict. + Conflict, + /// Requested durable entity does not exist. + NotFound, + /// The actor is unauthenticated or lacks access. + Unauthorized, + /// OS or capability policy denied the request. + PolicyDenied, + /// The selected Agent Runtime is unavailable. + RuntimeUnavailable, + /// A transport failed before a domain result was known. + Transport, + /// Durable state could not be read or committed. + Storage, + /// The operation was cancelled. + Cancelled, + /// An invariant failed without a safe public diagnostic. + Internal, +} + +/// Safe bounded failure exposed by domain and transport envelopes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractError { + /// Stable machine-readable code. + pub code: ErrorCode, + /// Broad failure category. + pub category: ErrorCategory, + /// Whether retry policy may consider the operation again. + pub retryable: bool, + /// Redacted message safe for a caller. + pub safe_message: BoundedText, + /// Optional minimum delay before retrying. + pub retry_after_ms: Option, + /// Optional opaque reference to separately governed diagnostic evidence. + pub details_ref: Option, +} + +impl ContractError { + /// Constructs a bounded error without diagnostic details. + pub fn new( + code: impl Into, + category: ErrorCategory, + retryable: bool, + safe_message: impl Into, + ) -> Result { + Ok(Self { + code: ErrorCode::parse(code)?, + category, + retryable, + safe_message: BoundedText::new(safe_message)?, + retry_after_ms: None, + details_ref: None, + }) + } +} + +/// Failure returned while constructing a bounded contract error. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ContractErrorBuildError { + /// The stable code is invalid. + #[error(transparent)] + Code(#[from] ErrorCodeError), + /// The safe message violates its bounded-string contract. + #[error(transparent)] + SafeMessage(#[from] BoundedStringError), +} diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/external.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/external.rs new file mode 100644 index 0000000000..8e48b7a348 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/external.rs @@ -0,0 +1,46 @@ +//! Scoped references for identities allocated outside COSH. + +use serde::{Deserialize, Serialize}; + +use crate::common::{BoundedName, BoundedOpaque, Digest}; + +/// Namespace of an external reference. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalRefKind { + /// Channel conversation scoped by adapter and tenant authority. + ChannelConversation, + /// Channel message scoped by its conversation. + ChannelMessage, + /// Shell process or interactive session. + ShellSession, + /// Shell command scoped by its session. + ShellCommand, + /// Provider-owned Agent conversation. + ProviderSession, + /// Locally allocated ACP transport connection. + AcpConnection, + /// ACP Agent session scoped by a connection. + AcpSession, + /// ACP JSON-RPC request scoped by a connection. + AcpRequest, + /// ACP message scoped by an Agent session. + AcpMessage, + /// ACP tool call scoped by an Agent session. + AcpToolCall, + /// ACP terminal scoped by a live Runtime binding. + Terminal, +} + +/// Opaque external identity that is meaningful only in its declared scope. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ExternalRef { + /// External namespace represented by the value. + pub kind: ExternalRefKind, + /// Adapter, tenant, provider, or connection authority. + pub authority: BoundedName, + /// Digest of the complete parent scope. + pub scope_digest: Digest, + /// Bounded opaque identity supplied by the external system. + pub value: BoundedOpaque, +} diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/ids.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/ids.rs new file mode 100644 index 0000000000..97b9361b64 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/ids.rs @@ -0,0 +1,154 @@ +//! Strongly typed identities allocated by COSH. + +use std::{fmt, str::FromStr}; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; +use uuid::Uuid; + +/// Failure returned when an internal identifier is not canonical for its type. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum IdError { + /// The expected type prefix is absent or belongs to another ID type. + #[error("identifier prefix must be `{expected}`")] + WrongPrefix { + /// Prefix required by the requested ID type. + expected: &'static str, + }, + /// The identifier body is not a canonical lowercase hyphenated UUID. + #[error("identifier body must be a canonical lowercase hyphenated UUID")] + InvalidUuid, +} + +macro_rules! define_id { + ($name:ident, $prefix:literal, $doc:literal) => { + #[doc = $doc] + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct $name(String); + + impl $name { + /// Prefix used in the stable text representation. + pub const PREFIX: &'static str = $prefix; + + /// Allocates a new identifier using the workspace UUID generator. + #[must_use] + pub fn new() -> Self { + Self(format!("{}_{}", Self::PREFIX, Uuid::new_v4().hyphenated())) + } + + /// Parses and validates a canonical identifier of this exact type. + pub fn parse(value: impl AsRef) -> Result { + let value = value.as_ref(); + let expected_prefix = format!("{}_", Self::PREFIX); + let body = value + .strip_prefix(&expected_prefix) + .ok_or(IdError::WrongPrefix { + expected: Self::PREFIX, + })?; + let uuid = Uuid::parse_str(body).map_err(|_| IdError::InvalidUuid)?; + if uuid.hyphenated().to_string() != body { + return Err(IdError::InvalidUuid); + } + Ok(Self(value.to_owned())) + } + + /// Returns the canonical prefixed representation. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = IdError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } + } + }; +} + +define_id!( + InstallationId, + "ins", + "Identifies one durable COSH Gateway installation." +); +define_id!(ActorId, "act", "Identifies an authenticated COSH actor."); +define_id!(TaskId, "tsk", "Identifies one durable user intent."); +define_id!(RunId, "run", "Identifies one attempt to execute a task."); +define_id!( + AgentSessionId, + "ags", + "Identifies one COSH-owned logical Agent session." +); +define_id!( + ShellSessionId, + "shs", + "Identifies one COSH-owned Shell session." +); +define_id!( + RuntimeInstanceId, + "rti", + "Identifies one supervised runtime process instance." +); +define_id!( + RuntimeBindingId, + "rtb", + "Identifies a fenced binding between a run and runtime session." +); +define_id!( + ApprovalId, + "apr", + "Identifies one durable approval request." +); +define_id!(PermitId, "prm", "Identifies one capability permit."); +define_id!( + ExecutionId, + "exe", + "Identifies one attempted governed side effect." +); +define_id!(DeliveryId, "dlv", "Identifies one presentation delivery."); +define_id!( + MessageId, + "msg", + "Identifies one COSH command or event envelope." +); +define_id!(RequestId, "req", "Identifies one COSH capability request."); +define_id!(ToolUseId, "tol", "Identifies one observed Agent tool call."); +define_id!( + RuntimeMessageId, + "rms", + "Identifies one logical message emitted by an Agent runtime." +); diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/lib.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/lib.rs new file mode 100644 index 0000000000..0f42699984 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/lib.rs @@ -0,0 +1,14 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +//! Side-effect-free domain contracts shared by COSH Gateway adapters. +//! +//! Transport and persistence crates translate through these types instead of +//! exposing ACP, Shell, channel, or database-specific payloads to the domain. + +pub mod capability; +pub mod common; +pub mod error; +pub mod external; +pub mod ids; +pub mod runtime; +pub mod task; diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/runtime.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/runtime.rs new file mode 100644 index 0000000000..d44e2bcc95 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/runtime.rs @@ -0,0 +1,242 @@ +//! Neutral commands and events for Agent Runtime bridges. + +use serde::{de, Deserialize, Deserializer, Serialize}; + +use crate::{ + capability::{CapabilityRequest, DenialCode}, + common::{ + BoundedName, BoundedText, ContentPart, ContractHeader, ContractSchema, RuntimeBindingRef, + WorkspaceRef, + }, + error::ContractError, + ids::{PermitId, RequestId, RunId, RuntimeBindingId, RuntimeMessageId, TaskId, ToolUseId}, + task::CancelReason, +}; + +/// Runtime-facing result of a capability decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum RuntimePermissionDecision { + /// Policy granted a permit bound to the request. + Permit { + /// Permit issued by the capability broker. + permit_id: PermitId, + }, + /// Policy denied the Runtime request. + Deny { + /// Stable reason for denial. + code: DenialCode, + /// Redacted explanation safe to send to the Runtime. + safe_message: BoundedText, + }, +} + +/// Command issued through the neutral Agent Runtime port. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum AgentRuntimeCommand { + /// Open a new provider or ACP session for a Task Run. + OpenSession { + /// Task owning the session. + task_id: TaskId, + /// Run opening the session. + run_id: RunId, + /// Workspace scope exposed to the Runtime. + workspace: WorkspaceRef, + }, + /// Resume an existing fenced session binding. + ResumeSession { + /// Task owning the session. + task_id: TaskId, + /// Run resuming the session. + run_id: RunId, + /// Existing fenced binding. + binding: RuntimeBindingRef, + }, + /// Send bounded content to an active Agent turn. + Prompt { + /// Run receiving the input. + run_id: RunId, + /// Neutral content parts. + input: Vec, + }, + /// Return a broker decision to a pending Runtime request. + ResolvePermission { + /// Capability request being resolved. + request_id: RequestId, + /// Broker decision translated for the Runtime. + decision: RuntimePermissionDecision, + }, + /// Request cancellation of an active Agent turn. + Cancel { + /// Run to cancel. + run_id: RunId, + /// Stable cancellation cause. + cause: CancelReason, + }, + /// Close a Runtime session binding. + Close { + /// Binding to close. + binding: RuntimeBindingRef, + }, +} + +/// Bounded token accounting reported by an Agent Runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeUsage { + /// Input tokens consumed during the Run. + pub input_tokens: u64, + /// Output tokens produced during the Run. + pub output_tokens: u64, +} + +/// Redacted description of a Runtime-observed tool call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolSummary { + /// Provider-independent tool name. + pub name: BoundedName, + /// Safe bounded description suitable for presentation. + pub summary: BoundedText, +} + +/// Terminal result reported by an Agent Runtime turn. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum RunOutcome { + /// Runtime turn completed successfully. + Succeeded, + /// Runtime turn completed with a bounded failure. + Failed { + /// Safe Runtime failure. + error: ContractError, + }, + /// Runtime acknowledged cancellation. + Cancelled, +} + +/// Event emitted by a provider, Core, or ACP Runtime bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum AgentRuntimeEvent { + /// A provider or ACP session was opened and fenced. + SessionOpened { + /// New active binding. + binding: RuntimeBindingRef, + }, + /// A bounded streaming content part was observed. + MessageChunk { + /// Runtime message receiving the chunk. + message_id: RuntimeMessageId, + /// Neutral content part. + content: ContentPart, + }, + /// Runtime reported a tool call without authorizing a side effect. + ToolCallObserved { + /// COSH-owned tool observation identity. + tool_use_id: ToolUseId, + /// Redacted tool summary. + summary: ToolSummary, + }, + /// Runtime requested permission for a capability. + PermissionRequested { + /// Neutral capability request evaluated by the broker. + request: CapabilityRequest, + }, + /// Runtime reported cumulative token usage. + UsageUpdated { + /// Current cumulative usage. + usage: RuntimeUsage, + }, + /// Runtime turn reached a terminal outcome. + Completed { + /// Terminal turn result. + outcome: RunOutcome, + }, + /// Runtime transport failed before a domain result was known. + TransportFailed { + /// Safe bounded transport error. + error: ContractError, + }, +} + +/// Versioned envelope for commands sent to an Agent Runtime bridge. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeCommandEnvelope { + /// Versioned envelope metadata. + pub header: ContractHeader, + /// Neutral Runtime command. + pub command: AgentRuntimeCommand, +} + +impl RuntimeCommandEnvelope { + /// Rejects a header that does not declare the Runtime command schema. + pub fn validate_schema(&self) -> Result<(), crate::common::EnvelopeSchemaError> { + self.header.validate_schema(ContractSchema::RuntimeCommand) + } +} + +impl<'de> Deserialize<'de> for RuntimeCommandEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireEnvelope { + header: ContractHeader, + command: AgentRuntimeCommand, + } + + let wire = WireEnvelope::deserialize(deserializer)?; + let envelope = Self { + header: wire.header, + command: wire.command, + }; + envelope.validate_schema().map_err(de::Error::custom)?; + Ok(envelope) + } +} + +/// Versioned event emitted by one fenced Agent Runtime binding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeEventEnvelope { + /// Versioned envelope metadata. + pub header: ContractHeader, + /// Fenced binding that produced the event. + pub binding_id: RuntimeBindingId, + /// Monotonic sequence assigned within the binding. + pub sequence: u64, + /// Neutral Runtime event. + pub event: AgentRuntimeEvent, +} + +impl RuntimeEventEnvelope { + /// Rejects a header that does not declare the Runtime event schema. + pub fn validate_schema(&self) -> Result<(), crate::common::EnvelopeSchemaError> { + self.header.validate_schema(ContractSchema::RuntimeEvent) + } +} + +impl<'de> Deserialize<'de> for RuntimeEventEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireEnvelope { + header: ContractHeader, + binding_id: RuntimeBindingId, + sequence: u64, + event: AgentRuntimeEvent, + } + + let wire = WireEnvelope::deserialize(deserializer)?; + let envelope = Self { + header: wire.header, + binding_id: wire.binding_id, + sequence: wire.sequence, + event: wire.event, + }; + envelope.validate_schema().map_err(de::Error::custom)?; + Ok(envelope) + } +} diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/src/task.rs b/src/cosh-ng/crates/cosh-gateway-contracts/src/task.rs new file mode 100644 index 0000000000..d2949a8868 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/src/task.rs @@ -0,0 +1,465 @@ +//! Task ingress commands and durable lifecycle events. + +use serde::{de, Deserialize, Deserializer, Serialize}; + +use crate::{ + capability::{ApprovalDecision, ApprovalRequest}, + common::{ + ActorRef, BoundedName, BoundedText, ContentPart, ContractHeader, ContractSchema, Digest, + IdempotencyKey, RuntimeBindingRef, RuntimeSelector, TargetRef, + }, + error::ContractError, + ids::{ApprovalId, ExecutionId, PermitId, RunId, TaskId}, +}; + +/// Opaque cursor used to resume a Task event attachment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct EventCursor( + /// Last Task revision observed by the client. + pub u64, +); + +/// Reason supplied when cancellation is requested. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CancelReason { + /// An authenticated actor requested cancellation. + UserRequested, + /// Policy revoked access or terminated the operation. + PolicyRevoked, + /// A deadline expired. + Timeout, + /// Runtime shutdown requires cancellation. + RuntimeShutdown, +} + +/// Terminal or intermediate stage at which cancellation completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CancellationStage { + /// Cancellation completed before a Runtime started. + BeforeRuntime, + /// Cancellation completed during an Agent turn. + Runtime, + /// Cancellation completed during governed execution. + Execution, +} + +/// Stable reason why a Run is suspended instead of completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SuspensionCode { + /// The selected Runtime became unavailable. + RuntimeUnavailable, + /// The Run awaits approval. + AwaitingApproval, + /// Operator intervention is required. + OperatorRequired, +} + +/// Stable reason why an execution result cannot be determined safely. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UncertaintyCode { + /// Transport failed after side-effect admission. + TransportLost, + /// Executor restarted before recording a terminal result. + ExecutorRestarted, + /// Reconciliation could not prove the outcome. + ReconciliationFailed, +} + +/// Result of one governed execution attempt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum ExecutionOutcome { + /// Side effect completed successfully. + Succeeded { + /// Optional bounded reference to execution evidence. + evidence_ref: Option, + }, + /// Side effect failed with a safe domain error. + Failed { + /// Bounded failure returned by the executor. + error: ContractError, + }, +} + +/// Runtime progress recorded as a durable Task event. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "update", rename_all = "snake_case")] +pub enum RuntimeUpdate { + /// Bounded progress text safe for presentation. + Progress { + /// Redacted progress summary. + summary: BoundedText, + }, + /// Runtime observed a tool call without authorizing execution. + ToolObserved { + /// Bounded tool name. + name: BoundedName, + /// Digest of normalized tool input. + input_digest: Digest, + }, +} + +/// Command admitted through the Gateway domain boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum TaskCommand { + /// Create a new durable Task. + CreateTask { + /// User intent after ingress validation. + intent: BoundedText, + /// Environment governed by the Task. + target: TargetRef, + }, + /// Start a new Runtime attempt for an existing Task. + StartRun { + /// Task to execute. + task_id: TaskId, + /// Runtime requested for the attempt. + runtime: RuntimeSelector, + }, + /// Supply additional input to an existing Task. + SubmitInput { + /// Task receiving the input. + task_id: TaskId, + /// Neutral bounded content parts. + content: Vec, + }, + /// Resolve a durable approval request. + ResolveApproval { + /// Approval being resolved. + approval_id: ApprovalId, + /// Authenticated actor decision. + decision: ApprovalDecision, + }, + /// Request cancellation of one active Run. + CancelRun { + /// Task owning the Run. + task_id: TaskId, + /// Run to cancel. + run_id: RunId, + /// Stable cancellation cause. + reason: CancelReason, + }, + /// Attach to Task events after an optional cursor. + Attach { + /// Task to observe. + task_id: TaskId, + /// Last revision already observed. + cursor: Option, + }, +} + +/// Authenticated and idempotent Gateway command envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewayCommandEnvelope { + /// Versioned envelope metadata. + pub header: ContractHeader, + /// Authenticated actor resolved by the ingress boundary. + pub actor: ActorRef, + /// Caller-scoped replay key. + pub idempotency_key: IdempotencyKey, + /// Optional optimistic concurrency precondition. + pub expected_task_revision: Option, + /// Neutral Task command. + pub command: TaskCommand, +} + +impl GatewayCommandEnvelope { + /// Rejects a header that does not declare the Gateway command schema. + pub fn validate_schema(&self) -> Result<(), crate::common::EnvelopeSchemaError> { + self.header.validate_schema(ContractSchema::GatewayCommand) + } +} + +impl<'de> Deserialize<'de> for GatewayCommandEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireEnvelope { + header: ContractHeader, + actor: ActorRef, + idempotency_key: IdempotencyKey, + expected_task_revision: Option, + command: TaskCommand, + } + + let wire = WireEnvelope::deserialize(deserializer)?; + let envelope = Self { + header: wire.header, + actor: wire.actor, + idempotency_key: wire.idempotency_key, + expected_task_revision: wire.expected_task_revision, + command: wire.command, + }; + envelope.validate_schema().map_err(de::Error::custom)?; + Ok(envelope) + } +} + +/// Durable Task lifecycle state used by projections and API responses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskState { + /// Task exists but no Run is active. + Submitted, + /// Task is queued for a Runtime. + Queued, + /// A Run is active. + Running, + /// A Run is paused while an actor resolves an approval. + WaitingApproval, + /// A Run is paused while an actor supplies additional input. + WaitingInput, + /// A Run is suspended pending an external condition. + Suspended, + /// Task completed successfully. + Succeeded, + /// Task completed with failure. + Failed, + /// Task was cancelled. + Cancelled, +} + +/// Stable discriminator for a [`TaskEvent`] without inspecting its payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskEventKind { + /// A Task was accepted. + TaskSubmitted, + /// A Run was queued. + TaskQueued, + /// A Run started. + RunStarted, + /// A Runtime session became bound to a Run. + RuntimeBound, + /// Runtime progress was recorded. + RuntimeEventRecorded, + /// Capability policy requested approval. + ApprovalRequested, + /// Approval was resolved. + ApprovalResolved, + /// A governed execution was planned. + ExecutionPlanned, + /// A governed execution completed. + ExecutionResultRecorded, + /// Execution outcome became uncertain. + ExecutionUncertain, + /// Cancellation intent was recorded. + CancellationRequested, + /// A Run completed cancellation. + RunCancelled, + /// A Run was suspended. + RunSuspended, + /// A Run succeeded. + RunSucceeded, + /// A Run failed. + RunFailed, + /// A retry Run was queued. + RunRetryQueued, + /// The Task succeeded. + TaskSucceeded, + /// The Task failed. + TaskFailed, + /// The Task was cancelled. + TaskCancelled, +} + +/// Immutable fact in a Task lifecycle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum TaskEvent { + /// A validated intent was accepted as a Task. + TaskSubmitted { + /// Digest of the admitted intent payload. + intent_digest: Digest, + /// Environment governed by the Task. + target: TargetRef, + }, + /// A new Run was queued for a Runtime. + TaskQueued { + /// New execution attempt. + run_id: RunId, + /// Selected Runtime. + runtime: RuntimeSelector, + }, + /// A queued Run started. + RunStarted { + /// Active Run. + run_id: RunId, + }, + /// A fenced Runtime session became active for a Run. + RuntimeBound { + /// Active Run. + run_id: RunId, + /// Fenced Runtime binding. + binding: RuntimeBindingRef, + }, + /// Neutral Runtime progress was accepted by the coordinator. + RuntimeEventRecorded { + /// Run that produced the update. + run_id: RunId, + /// Recorded progress update. + update: RuntimeUpdate, + }, + /// Capability policy requires an actor decision. + ApprovalRequested { + /// Durable approval request. + approval: ApprovalRequest, + }, + /// An authorized actor resolved an approval. + ApprovalResolved { + /// Resolved approval. + approval_id: ApprovalId, + /// Actor decision. + decision: ApprovalDecision, + }, + /// A permit was bound to an execution identity. + ExecutionPlanned { + /// Governed execution attempt. + execution_id: ExecutionId, + /// Permit authorizing the attempt. + permit_id: PermitId, + }, + /// An execution reached a known terminal result. + ExecutionResultRecorded { + /// Governed execution attempt. + execution_id: ExecutionId, + /// Known terminal result. + outcome: ExecutionOutcome, + }, + /// An execution may have produced a side effect but has no proven result. + ExecutionUncertain { + /// Governed execution attempt. + execution_id: ExecutionId, + /// Stable uncertainty cause. + reason: UncertaintyCode, + }, + /// Cancellation intent was persisted for a Run. + CancellationRequested { + /// Run being cancelled. + run_id: RunId, + /// Stable cancellation cause. + cause: CancelReason, + }, + /// A Run completed cancellation. + RunCancelled { + /// Cancelled Run. + run_id: RunId, + /// Lifecycle stage that observed cancellation. + stage: CancellationStage, + }, + /// A Run paused without becoming terminal. + RunSuspended { + /// Suspended Run. + run_id: RunId, + /// Stable suspension cause. + reason: SuspensionCode, + }, + /// A Run completed successfully. + RunSucceeded { + /// Successful Run. + run_id: RunId, + }, + /// A Run completed with failure. + RunFailed { + /// Failed Run. + run_id: RunId, + /// Bounded terminal error. + error: ContractError, + }, + /// A failed or suspended attempt produced a new Run. + RunRetryQueued { + /// Previous attempt. + previous_run_id: RunId, + /// New retry attempt. + next_run_id: RunId, + }, + /// The Task completed successfully. + TaskSucceeded, + /// The Task completed with failure. + TaskFailed { + /// Bounded terminal error. + error: ContractError, + }, + /// The Task completed cancellation. + TaskCancelled, +} + +impl TaskEvent { + /// Returns the stable payload-independent event discriminator. + #[must_use] + pub const fn kind(&self) -> TaskEventKind { + match self { + Self::TaskSubmitted { .. } => TaskEventKind::TaskSubmitted, + Self::TaskQueued { .. } => TaskEventKind::TaskQueued, + Self::RunStarted { .. } => TaskEventKind::RunStarted, + Self::RuntimeBound { .. } => TaskEventKind::RuntimeBound, + Self::RuntimeEventRecorded { .. } => TaskEventKind::RuntimeEventRecorded, + Self::ApprovalRequested { .. } => TaskEventKind::ApprovalRequested, + Self::ApprovalResolved { .. } => TaskEventKind::ApprovalResolved, + Self::ExecutionPlanned { .. } => TaskEventKind::ExecutionPlanned, + Self::ExecutionResultRecorded { .. } => TaskEventKind::ExecutionResultRecorded, + Self::ExecutionUncertain { .. } => TaskEventKind::ExecutionUncertain, + Self::CancellationRequested { .. } => TaskEventKind::CancellationRequested, + Self::RunCancelled { .. } => TaskEventKind::RunCancelled, + Self::RunSuspended { .. } => TaskEventKind::RunSuspended, + Self::RunSucceeded { .. } => TaskEventKind::RunSucceeded, + Self::RunFailed { .. } => TaskEventKind::RunFailed, + Self::RunRetryQueued { .. } => TaskEventKind::RunRetryQueued, + Self::TaskSucceeded => TaskEventKind::TaskSucceeded, + Self::TaskFailed { .. } => TaskEventKind::TaskFailed, + Self::TaskCancelled => TaskEventKind::TaskCancelled, + } + } +} + +/// Versioned event with a monotonic per-Task revision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TaskEventEnvelope { + /// Versioned envelope metadata. + pub header: ContractHeader, + /// Task owning the event stream. + pub task_id: TaskId, + /// Monotonic revision assigned by Task storage. + pub revision: u64, + /// Immutable Task lifecycle fact. + pub event: TaskEvent, +} + +impl TaskEventEnvelope { + /// Rejects a header that does not declare the Task event schema. + pub fn validate_schema(&self) -> Result<(), crate::common::EnvelopeSchemaError> { + self.header.validate_schema(ContractSchema::TaskEvent) + } +} + +impl<'de> Deserialize<'de> for TaskEventEnvelope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireEnvelope { + header: ContractHeader, + task_id: TaskId, + revision: u64, + event: TaskEvent, + } + + let wire = WireEnvelope::deserialize(deserializer)?; + let envelope = Self { + header: wire.header, + task_id: wire.task_id, + revision: wire.revision, + event: wire.event, + }; + envelope.validate_schema().map_err(de::Error::custom)?; + Ok(envelope) + } +} diff --git a/src/cosh-ng/crates/cosh-gateway-contracts/tests/contracts.rs b/src/cosh-ng/crates/cosh-gateway-contracts/tests/contracts.rs new file mode 100644 index 0000000000..110679dfa3 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway-contracts/tests/contracts.rs @@ -0,0 +1,251 @@ +use serde::{de::DeserializeOwned, Serialize}; + +use cosh_gateway_contracts::{ + capability::{ + ApprovalDecision, CapabilityDecision, CapabilityRequest, CapabilityScope, ExecutionPermit, + OperationDescriptor, + }, + common::{ + ActorKind, ActorRef, AuthAssurance, BoundedName, BoundedOpaque, BoundedStringError, + BoundedText, ContentPart, ContractHeader, ContractSchema, Correlation, Digest, + IdempotencyKey, TargetRef, CONTRACT_SCHEMA_VERSION, MAX_TEXT_BYTES, + }, + error::{ContractError, ErrorCategory}, + ids::{ + ActorId, AgentSessionId, ApprovalId, ExecutionId, InstallationId, MessageId, PermitId, + RequestId, RunId, RuntimeBindingId, ShellSessionId, TaskId, ToolUseId, + }, + runtime::{ + AgentRuntimeCommand, AgentRuntimeEvent, RunOutcome, RuntimeCommandEnvelope, + RuntimeEventEnvelope, + }, + task::{ + GatewayCommandEnvelope, TaskCommand, TaskEvent, TaskEventEnvelope, TaskEventKind, TaskState, + }, +}; + +fn digest(byte: char) -> Digest { + Digest::parse(byte.to_string().repeat(64)).expect("test digest is canonical") +} + +fn target() -> TargetRef { + TargetRef { + kind: BoundedName::new("ecs").expect("test name is bounded"), + authority: BoundedName::new("local").expect("test name is bounded"), + identifier: BoundedOpaque::new("instance-1").expect("test ID is bounded"), + } +} + +fn actor() -> ActorRef { + ActorRef { + actor_id: ActorId::new(), + actor_kind: ActorKind::Human, + issuer: BoundedName::new("local-os").expect("test issuer is bounded"), + assurance: AuthAssurance::LocalOs, + } +} + +fn header(schema: ContractSchema) -> ContractHeader { + ContractHeader::new( + schema, + MessageId::new(), + 1_700_000_000_000, + Correlation::new(InstallationId::new()), + ) +} + +fn assert_schema_mismatch_rejected(value: &T, wrong_schema: &str) +where + T: Serialize + DeserializeOwned, +{ + let mut json = serde_json::to_value(value).expect("envelope serializes"); + json["header"]["schema"] = serde_json::json!(wrong_schema); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn internal_id_types_reject_cross_parsing() { + let task_id = TaskId::new(); + assert!(RunId::parse(task_id.as_str()).is_err()); + assert!(RequestId::parse(task_id.as_str()).is_err()); + assert_eq!( + TaskId::parse(task_id.as_str()).expect("same ID type parses"), + task_id + ); + + let agent_session_id = AgentSessionId::new(); + assert!(ShellSessionId::parse(agent_session_id.as_str()).is_err()); + + let tool_use_id = ToolUseId::new(); + assert!(ExecutionId::parse(tool_use_id.as_str()).is_err()); + assert!(ApprovalId::parse(tool_use_id.as_str()).is_err()); +} + +#[test] +fn ids_serialize_as_validated_canonical_strings() { + let task_id = TaskId::new(); + let json = serde_json::to_string(&task_id).expect("ID serializes"); + let decoded: TaskId = serde_json::from_str(&json).expect("canonical ID deserializes"); + assert_eq!(decoded, task_id); + assert!( + serde_json::from_str::("\"run_00000000-0000-0000-0000-000000000000\"").is_err() + ); +} + +#[test] +fn task_command_and_event_envelopes_round_trip() { + let command = GatewayCommandEnvelope { + header: header(ContractSchema::GatewayCommand), + actor: actor(), + idempotency_key: IdempotencyKey::new("channel-message-1").expect("test key is bounded"), + expected_task_revision: None, + command: TaskCommand::CreateTask { + intent: BoundedText::new("inspect disk pressure").expect("test text is bounded"), + target: target(), + }, + }; + let command_json = serde_json::to_string(&command).expect("command serializes"); + let command_decoded: GatewayCommandEnvelope = + serde_json::from_str(&command_json).expect("command deserializes"); + assert_eq!(command_decoded, command); + assert_schema_mismatch_rejected(&command, "cosh.runtime.command"); + + let event = TaskEventEnvelope { + header: header(ContractSchema::TaskEvent), + task_id: TaskId::new(), + revision: 1, + event: TaskEvent::TaskSubmitted { + intent_digest: digest('a'), + target: target(), + }, + }; + let event_json = serde_json::to_string(&event).expect("event serializes"); + let event_decoded: TaskEventEnvelope = + serde_json::from_str(&event_json).expect("event deserializes"); + assert_eq!(event_decoded, event); + assert_eq!(event_decoded.event.kind(), TaskEventKind::TaskSubmitted); + assert_schema_mismatch_rejected(&event, "cosh.gateway.command"); + + assert_eq!( + serde_json::to_string(&TaskState::WaitingApproval).expect("state serializes"), + "\"waiting_approval\"" + ); + assert_eq!( + serde_json::to_string(&TaskState::WaitingInput).expect("state serializes"), + "\"waiting_input\"" + ); +} + +#[test] +fn runtime_and_capability_contracts_round_trip() { + let task_id = TaskId::new(); + let run_id = RunId::new(); + let request_id = RequestId::new(); + let request = CapabilityRequest { + request_id: request_id.clone(), + task_id: task_id.clone(), + run_id: run_id.clone(), + actor: actor(), + target: target(), + operation: OperationDescriptor { + namespace: BoundedName::new("process").expect("test name is bounded"), + name: BoundedName::new("spawn").expect("test name is bounded"), + arguments_digest: digest('b'), + }, + operation_digest: digest('e'), + requested_scope: CapabilityScope { + resource: BoundedName::new("host").expect("test name is bounded"), + access: BoundedName::new("execute").expect("test name is bounded"), + }, + input_digest: digest('c'), + expires_at_ms: 1_700_000_001_000, + }; + let permit = ExecutionPermit { + permit_id: PermitId::new(), + request_id, + actor_id: request.actor.actor_id.clone(), + approval_id: Some(ApprovalId::new()), + task_id, + run_id: run_id.clone(), + execution_id: ExecutionId::new(), + target: target(), + operation_digest: digest('d'), + input_digest: request.input_digest.clone(), + policy_revision: 7, + valid_until_ms: 1_700_000_001_000, + single_use: true, + }; + let decision = CapabilityDecision::Permit { permit }; + let decision_json = serde_json::to_string(&decision).expect("decision serializes"); + let decision_decoded: CapabilityDecision = + serde_json::from_str(&decision_json).expect("decision deserializes"); + assert_eq!(decision_decoded, decision); + + let runtime = RuntimeCommandEnvelope { + header: header(ContractSchema::RuntimeCommand), + command: AgentRuntimeCommand::Prompt { + run_id, + input: vec![ContentPart::Text { + text: BoundedText::new("continue").expect("test text is bounded"), + }], + }, + }; + let runtime_json = serde_json::to_string(&runtime).expect("Runtime command serializes"); + let runtime_decoded: RuntimeCommandEnvelope = + serde_json::from_str(&runtime_json).expect("Runtime command deserializes"); + assert_eq!(runtime_decoded, runtime); + assert_schema_mismatch_rejected(&runtime, "cosh.runtime.event"); + + let runtime_event = RuntimeEventEnvelope { + header: header(ContractSchema::RuntimeEvent), + binding_id: RuntimeBindingId::new(), + sequence: 1, + event: AgentRuntimeEvent::Completed { + outcome: RunOutcome::Succeeded, + }, + }; + let runtime_event_json = + serde_json::to_string(&runtime_event).expect("Runtime event serializes"); + let runtime_event_decoded: RuntimeEventEnvelope = + serde_json::from_str(&runtime_event_json).expect("Runtime event deserializes"); + assert_eq!(runtime_event_decoded, runtime_event); + assert_schema_mismatch_rejected(&runtime_event, "cosh.task.event"); + + assert_eq!(ApprovalDecision::Approve, ApprovalDecision::Approve); + assert_eq!(request.task_id.as_str().split('_').next(), Some("tsk")); +} + +#[test] +fn contract_header_version_is_independent_and_fail_closed() { + let supported = header(ContractSchema::GatewayCommand); + assert_eq!(supported.schema_version, CONTRACT_SCHEMA_VERSION); + + let mut value = serde_json::to_value(supported).expect("header serializes"); + value["schema_version"] = serde_json::json!(CONTRACT_SCHEMA_VERSION + 1); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn contract_errors_are_bounded_during_construction_and_deserialization() { + let error = ContractError::new( + "runtime_unavailable", + ErrorCategory::RuntimeUnavailable, + true, + "runtime is temporarily unavailable", + ) + .expect("test error is bounded"); + let json = serde_json::to_string(&error).expect("error serializes"); + let decoded: ContractError = serde_json::from_str(&json).expect("error deserializes"); + assert_eq!(decoded, error); + + assert_eq!( + BoundedText::new("x".repeat(MAX_TEXT_BYTES + 1)), + Err(BoundedStringError::TooLong { + max_bytes: MAX_TEXT_BYTES + }) + ); + + let mut oversized = serde_json::to_value(error).expect("error serializes"); + oversized["safe_message"] = serde_json::json!("x".repeat(MAX_TEXT_BYTES + 1)); + assert!(serde_json::from_value::(oversized).is_err()); +} diff --git a/src/cosh-ng/crates/cosh-gateway/Cargo.toml b/src/cosh-ng/crates/cosh-gateway/Cargo.toml new file mode 100644 index 0000000000..4e58b11299 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cosh-gateway" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +agent-client-protocol = { workspace = true } +cosh-gateway-contracts = { path = "../cosh-gateway-contracts" } +nix = { workspace = true } +rusqlite = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +wait-timeout = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/src/cosh-ng/crates/cosh-gateway/src/capability.rs b/src/cosh-ng/crates/cosh-gateway/src/capability.rs new file mode 100644 index 0000000000..7a005d13bc --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/capability.rs @@ -0,0 +1,11 @@ +//! Fail-closed capability policy and single-use permit coordination. + +mod broker; +mod memory; + +pub use broker::{ + AuthoritativeRequestBinding, BrokerError, CapabilityBroker, ParentBinding, PermitClaim, + PermitExpectation, PermitStore, PermitStoreError, PolicyDecision, PolicyError, PolicyPort, + RequestContext, +}; +pub use memory::MemoryPermitStore; diff --git a/src/cosh-ng/crates/cosh-gateway/src/capability/broker.rs b/src/cosh-ng/crates/cosh-gateway/src/capability/broker.rs new file mode 100644 index 0000000000..5941293a11 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/capability/broker.rs @@ -0,0 +1,377 @@ +//! Policy orchestration and permit verification without executing OS effects. + +use cosh_gateway_contracts::{ + capability::{ + ApprovalRequest, CapabilityDecision, CapabilityRequest, CapabilityScope, DenialCode, + ExecutionPermit, OperationDescriptor, + }, + common::{ActorRef, BoundedText, Digest, TargetRef}, + ids::{ActorId, ExecutionId, PermitId, RunId, TaskId}, +}; +use thiserror::Error; + +/// Authoritative parent identities against which a request is admitted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentBinding { + /// Task that owns the capability request. + pub task_id: TaskId, + /// Active Run that produced the request. + pub run_id: RunId, + /// Complete authenticated actor provenance for policy evaluation. + pub actor: ActorRef, +} + +/// Content pinned by trusted admission before policy evaluation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthoritativeRequestBinding { + /// Exact target selected by trusted admission. + pub target: TargetRef, + /// Complete normalized operation descriptor shown to policy. + pub operation: OperationDescriptor, + /// Digest of the complete canonical operation. + pub operation_digest: Digest, + /// Exact resource and access scope shown to policy. + pub requested_scope: CapabilityScope, + /// Digest of the complete Runtime input shown to policy. + pub input_digest: Digest, +} + +/// Time and parent state supplied by the Task coordinator at authorization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestContext { + /// Current wall-clock time in milliseconds since the Unix epoch. + pub now_ms: u64, + /// Authoritative Task, Run, and actor relationship. + pub parent: ParentBinding, + /// Target, operation, digest, and scope pinned by trusted admission. + pub binding: AuthoritativeRequestBinding, +} + +/// Provider-neutral policy result consumed by the capability broker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyDecision { + /// Policy denies the request without issuing executable authority. + Deny { + /// Stable denial classification. + code: DenialCode, + /// Redacted explanation safe for the requesting Runtime. + safe_message: BoundedText, + }, + /// Policy requires a durable actor decision before re-authorization. + RequireApproval { + /// Redacted explanation shown to an authorized approver. + summary: BoundedText, + /// Policy revision that evaluated the request. + policy_revision: u64, + /// Latest millisecond timestamp at which this decision remains valid. + valid_until_ms: u64, + }, + /// Policy permits one exact execution within a bounded lifetime. + Allow { + /// Policy revision that evaluated the request. + policy_revision: u64, + /// Latest millisecond timestamp at which this decision remains valid. + valid_until_ms: u64, + }, +} + +/// Failure returned by a policy adapter before a decision is available. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PolicyError { + /// Policy could not be loaded or evaluated safely. + #[error("capability policy is unavailable")] + Unavailable, +} + +/// Evaluates a validated capability request without issuing a permit directly. +pub trait PolicyPort { + /// Returns the current policy decision for the exact normalized request. + fn evaluate(&self, request: &CapabilityRequest) -> Result; +} + +/// Exact values that a caller must prove before consuming a permit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermitExpectation { + /// Permit selected for atomic validation and consumption. + pub permit_id: PermitId, + /// Actor attempting to consume the permit. + pub actor_id: ActorId, + /// Task under which execution is being admitted. + pub task_id: TaskId, + /// Run under which execution is being admitted. + pub run_id: RunId, + /// Execution identity presented to the target adapter. + pub execution_id: ExecutionId, + /// Exact target selected immediately before execution. + pub target: TargetRef, + /// Digest of the normalized operation about to execute. + pub operation_digest: Digest, + /// Digest of the complete Runtime input about to execute. + pub input_digest: Digest, + /// Policy revision expected by the execution path. + pub policy_revision: u64, + /// Current wall-clock time in milliseconds since the Unix epoch. + pub now_ms: u64, +} + +/// A permit that passed exact binding checks and was consumed atomically. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermitClaim { + permit: ExecutionPermit, +} + +impl PermitClaim { + /// Returns the consumed permit for audit and execution correlation. + #[must_use] + pub fn permit(&self) -> &ExecutionPermit { + &self.permit + } +} + +/// Failures produced by the atomic permit ledger boundary. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PermitStoreError { + /// The generated permit identity already exists. + #[error("permit already exists")] + AlreadyExists, + /// The selected permit is unknown. + #[error("permit not found")] + NotFound, + /// The permit has reached or passed its expiry. + #[error("permit expired")] + Expired, + /// The single-use permit was already consumed. + #[error("permit already consumed")] + AlreadyConsumed, + /// The permit is not restricted to one use. + #[error("permit is not single-use")] + NotSingleUse, + /// The presented actor differs from the permit binding. + #[error("permit actor mismatch")] + ActorMismatch, + /// The presented Task differs from the permit binding. + #[error("permit Task mismatch")] + TaskMismatch, + /// The presented Run differs from the permit binding. + #[error("permit Run mismatch")] + RunMismatch, + /// The presented Execution differs from the permit binding. + #[error("permit Execution mismatch")] + ExecutionMismatch, + /// The presented target differs from the permit binding. + #[error("permit target mismatch")] + TargetMismatch, + /// The presented operation digest differs from the permit binding. + #[error("permit operation digest mismatch")] + OperationMismatch, + /// The presented Runtime input digest differs from the permit binding. + #[error("permit input digest mismatch")] + InputMismatch, + /// The request identity was already bound to different authority. + #[error("capability request already issued with different authority")] + RequestConflict, + /// The presented policy revision differs from the permit binding. + #[error("permit policy revision mismatch")] + PolicyRevisionMismatch, + /// The ledger cannot prove a safe state transition. + #[error("permit store is unavailable")] + Unavailable, +} + +/// Stores issued permits and validates plus consumes them in one atomic step. +pub trait PermitStore { + /// Returns the first decision for an equivalent request retry, if present. + fn replay( + &self, + request: &CapabilityRequest, + ) -> Result, PermitStoreError>; + + /// Atomically records or replays the first authorization decision. + /// + /// Implementations must return the first decision when an equivalent retry + /// reuses a request identity and reject any changed request content. + fn issue_or_replay( + &self, + request: &CapabilityRequest, + decision: CapabilityDecision, + ) -> Result; + + /// Validates every expected binding and atomically consumes the permit. + fn consume(&self, expectation: &PermitExpectation) + -> Result; +} + +/// Fail-closed Capability Broker validation and dependency errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BrokerError { + /// The request has reached or passed its deadline. + #[error("capability request expired")] + RequestExpired, + /// Request Task does not match the authoritative parent binding. + #[error("capability request Task mismatch")] + RequestTaskMismatch, + /// Request Run does not match the authoritative parent binding. + #[error("capability request Run mismatch")] + RequestRunMismatch, + /// Request actor does not match the authenticated parent binding. + #[error("capability request actor mismatch")] + RequestActorMismatch, + /// Request target, operation, digest, or scope differs from trusted admission. + #[error("capability request content mismatch")] + RequestContentMismatch, + /// Policy returned an invalid zero revision. + #[error("capability policy revision must be non-zero")] + InvalidPolicyRevision, + /// Policy authority is already expired at evaluation time. + #[error("capability policy decision expired")] + PolicyDecisionExpired, + /// The policy adapter failed without a decision. + #[error(transparent)] + Policy(#[from] PolicyError), + /// Permit issuance or consumption failed closed. + #[error(transparent)] + Permit(#[from] PermitStoreError), +} + +/// Pure orchestration for policy decisions and single-use execution permits. +/// +/// The Broker treats `CapabilityRequest::operation_digest` as the trusted +/// ingress digest of the complete canonical operation. It never substitutes +/// the narrower argument-only digest when issuing authority. +#[derive(Debug)] +pub struct CapabilityBroker { + policy: P, + permits: S, +} + +impl CapabilityBroker +where + P: PolicyPort, + S: PermitStore, +{ + /// Creates a broker around explicit policy and permit-ledger boundaries. + #[must_use] + pub fn new(policy: P, permits: S) -> Self { + Self { policy, permits } + } + + /// Evaluates an admitted request and returns deny, approval, or permit. + /// + /// # Errors + /// + /// Fails when the request is expired, its parent identities do not match, + /// policy output is invalid, or the permit cannot be recorded atomically. + pub fn authorize( + &self, + request: &CapabilityRequest, + context: &RequestContext, + ) -> Result { + validate_request(request, context)?; + if let Some(decision) = self.permits.replay(request)? { + return Ok(decision); + } + let decision = match self.policy.evaluate(request)? { + PolicyDecision::Deny { code, safe_message } => { + CapabilityDecision::Deny { code, safe_message } + } + PolicyDecision::RequireApproval { + summary, + policy_revision, + valid_until_ms, + } => { + validate_policy_authority(policy_revision, valid_until_ms, context.now_ms)?; + CapabilityDecision::RequireApproval { + approval: ApprovalRequest { + approval_id: cosh_gateway_contracts::ids::ApprovalId::new(), + request_id: request.request_id.clone(), + task_id: request.task_id.clone(), + run_id: request.run_id.clone(), + summary, + expires_at_ms: valid_until_ms.min(request.expires_at_ms), + }, + } + } + PolicyDecision::Allow { + policy_revision, + valid_until_ms, + } => { + validate_policy_authority(policy_revision, valid_until_ms, context.now_ms)?; + let permit = ExecutionPermit { + permit_id: PermitId::new(), + request_id: request.request_id.clone(), + actor_id: request.actor.actor_id.clone(), + approval_id: None, + task_id: request.task_id.clone(), + run_id: request.run_id.clone(), + execution_id: ExecutionId::new(), + target: request.target.clone(), + operation_digest: request.operation_digest.clone(), + input_digest: request.input_digest.clone(), + policy_revision, + valid_until_ms: valid_until_ms.min(request.expires_at_ms), + single_use: true, + }; + CapabilityDecision::Permit { permit } + } + }; + self.permits + .issue_or_replay(request, decision) + .map_err(Into::into) + } + + /// Atomically validates and consumes one exact single-use permit. + /// + /// This method stops before any OS executor or target adapter is invoked. + /// + /// # Errors + /// + /// Fails closed for an unknown, expired, already consumed, non-single-use, + /// or incorrectly bound permit, and when the ledger is unavailable. + pub fn claim(&self, expectation: &PermitExpectation) -> Result { + let permit = self.permits.consume(expectation)?; + Ok(PermitClaim { permit }) + } +} + +fn validate_request( + request: &CapabilityRequest, + context: &RequestContext, +) -> Result<(), BrokerError> { + if request.expires_at_ms <= context.now_ms { + return Err(BrokerError::RequestExpired); + } + if request.task_id != context.parent.task_id { + return Err(BrokerError::RequestTaskMismatch); + } + if request.run_id != context.parent.run_id { + return Err(BrokerError::RequestRunMismatch); + } + if request.actor != context.parent.actor { + return Err(BrokerError::RequestActorMismatch); + } + let content = AuthoritativeRequestBinding { + target: request.target.clone(), + operation: request.operation.clone(), + operation_digest: request.operation_digest.clone(), + requested_scope: request.requested_scope.clone(), + input_digest: request.input_digest.clone(), + }; + if content != context.binding { + return Err(BrokerError::RequestContentMismatch); + } + Ok(()) +} + +fn validate_policy_authority( + policy_revision: u64, + valid_until_ms: u64, + now_ms: u64, +) -> Result<(), BrokerError> { + if policy_revision == 0 { + return Err(BrokerError::InvalidPolicyRevision); + } + if valid_until_ms <= now_ms { + return Err(BrokerError::PolicyDecisionExpired); + } + Ok(()) +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/capability/memory.rs b/src/cosh-ng/crates/cosh-gateway/src/capability/memory.rs new file mode 100644 index 0000000000..333f57d58b --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/capability/memory.rs @@ -0,0 +1,156 @@ +//! In-memory permit ledger for deterministic local coordination and tests. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use cosh_gateway_contracts::{ + capability::{CapabilityDecision, CapabilityRequest, ExecutionPermit}, + ids::{PermitId, RequestId}, +}; + +use super::{PermitExpectation, PermitStore, PermitStoreError}; + +#[derive(Debug, Clone)] +struct StoredPermit { + permit: ExecutionPermit, + consumed: bool, +} + +#[derive(Debug, Clone)] +struct StoredDecision { + request: CapabilityRequest, + decision: CapabilityDecision, +} + +/// Process-local permit store with mutex-atomic validation and consumption. +#[derive(Debug, Clone, Default)] +pub struct MemoryPermitStore { + ledger: Arc>, +} + +#[derive(Debug, Default)] +struct PermitLedger { + permits: HashMap, + requests: HashMap, +} + +impl PermitStore for MemoryPermitStore { + fn replay( + &self, + request: &CapabilityRequest, + ) -> Result, PermitStoreError> { + let ledger = self + .ledger + .lock() + .map_err(|_| PermitStoreError::Unavailable)?; + let Some(stored) = ledger.requests.get(&request.request_id) else { + return Ok(None); + }; + if stored.request != *request { + return Err(PermitStoreError::RequestConflict); + } + Ok(Some(stored.decision.clone())) + } + + fn issue_or_replay( + &self, + request: &CapabilityRequest, + decision: CapabilityDecision, + ) -> Result { + let mut ledger = self + .ledger + .lock() + .map_err(|_| PermitStoreError::Unavailable)?; + if let Some(stored) = ledger.requests.get(&request.request_id) { + if stored.request != *request { + return Err(PermitStoreError::RequestConflict); + } + return Ok(stored.decision.clone()); + } + if let CapabilityDecision::Permit { permit } = &decision { + if ledger.permits.contains_key(&permit.permit_id) { + return Err(PermitStoreError::AlreadyExists); + } + ledger.permits.insert( + permit.permit_id.clone(), + StoredPermit { + permit: permit.clone(), + consumed: false, + }, + ); + } + ledger.requests.insert( + request.request_id.clone(), + StoredDecision { + request: request.clone(), + decision: decision.clone(), + }, + ); + Ok(decision) + } + + fn consume( + &self, + expectation: &PermitExpectation, + ) -> Result { + let mut ledger = self + .ledger + .lock() + .map_err(|_| PermitStoreError::Unavailable)?; + let stored = ledger + .permits + .get_mut(&expectation.permit_id) + .ok_or(PermitStoreError::NotFound)?; + + // Validate under the same lock as the state transition so a mismatch + // cannot consume authority and two correct callers cannot both win. + validate_expectation(&stored.permit, expectation)?; + if stored.consumed { + return Err(PermitStoreError::AlreadyConsumed); + } + stored.consumed = true; + Ok(stored.permit.clone()) + } +} + +fn validate_expectation( + permit: &ExecutionPermit, + expectation: &PermitExpectation, +) -> Result<(), PermitStoreError> { + if !permit.single_use { + return Err(PermitStoreError::NotSingleUse); + } + if permit.valid_until_ms <= expectation.now_ms { + return Err(PermitStoreError::Expired); + } + if permit.actor_id != expectation.actor_id { + return Err(PermitStoreError::ActorMismatch); + } + if permit.task_id != expectation.task_id { + return Err(PermitStoreError::TaskMismatch); + } + if permit.run_id != expectation.run_id { + return Err(PermitStoreError::RunMismatch); + } + if permit.execution_id != expectation.execution_id { + return Err(PermitStoreError::ExecutionMismatch); + } + if permit.target != expectation.target { + return Err(PermitStoreError::TargetMismatch); + } + if permit.operation_digest != expectation.operation_digest { + return Err(PermitStoreError::OperationMismatch); + } + if permit.input_digest != expectation.input_digest { + return Err(PermitStoreError::InputMismatch); + } + if permit.policy_revision != expectation.policy_revision { + return Err(PermitStoreError::PolicyRevisionMismatch); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/cosh-ng/crates/cosh-gateway/src/capability/memory/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/capability/memory/tests.rs new file mode 100644 index 0000000000..7fbbff7cd6 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/capability/memory/tests.rs @@ -0,0 +1,522 @@ +use std::{ + sync::{Arc, Barrier}, + thread, +}; + +use cosh_gateway_contracts::{ + capability::{ + CapabilityDecision, CapabilityRequest, CapabilityScope, DenialCode, OperationDescriptor, + }, + common::{ + ActorKind, ActorRef, AuthAssurance, BoundedName, BoundedOpaque, BoundedText, Digest, + TargetRef, + }, + ids::{ActorId, ExecutionId, RequestId, RunId, TaskId}, +}; + +use super::super::{ + AuthoritativeRequestBinding, BrokerError, CapabilityBroker, MemoryPermitStore, ParentBinding, + PermitExpectation, PermitStoreError, PolicyDecision, PolicyError, PolicyPort, RequestContext, +}; + +#[derive(Debug, Clone)] +struct FixedPolicy(PolicyDecision); + +impl PolicyPort for FixedPolicy { + fn evaluate(&self, _request: &CapabilityRequest) -> Result { + Ok(self.0.clone()) + } +} + +#[derive(Debug, Clone)] +struct UnavailablePolicy; + +impl PolicyPort for UnavailablePolicy { + fn evaluate(&self, _request: &CapabilityRequest) -> Result { + Err(PolicyError::Unavailable) + } +} + +fn digest(byte: char) -> Digest { + Digest::parse(byte.to_string().repeat(64)).expect("test digest is canonical") +} + +fn target(value: &str) -> TargetRef { + TargetRef { + kind: BoundedName::new("ecs").expect("test target kind is bounded"), + authority: BoundedName::new("local").expect("test authority is bounded"), + identifier: BoundedOpaque::new(value).expect("test target ID is bounded"), + } +} + +fn request() -> CapabilityRequest { + CapabilityRequest { + request_id: RequestId::new(), + task_id: TaskId::new(), + run_id: RunId::new(), + actor: ActorRef { + actor_id: ActorId::new(), + actor_kind: ActorKind::Human, + issuer: BoundedName::new("local-os").expect("test issuer is bounded"), + assurance: AuthAssurance::LocalOs, + }, + target: target("instance-1"), + operation: OperationDescriptor { + namespace: BoundedName::new("service").expect("test namespace is bounded"), + name: BoundedName::new("restart").expect("test operation is bounded"), + arguments_digest: digest('a'), + }, + operation_digest: digest('c'), + requested_scope: CapabilityScope { + resource: BoundedName::new("systemd-unit").expect("test resource is bounded"), + access: BoundedName::new("mutate").expect("test access is bounded"), + }, + input_digest: digest('b'), + expires_at_ms: 2_000, + } +} + +fn context(request: &CapabilityRequest, now_ms: u64) -> RequestContext { + RequestContext { + now_ms, + parent: ParentBinding { + task_id: request.task_id.clone(), + run_id: request.run_id.clone(), + actor: request.actor.clone(), + }, + binding: AuthoritativeRequestBinding { + target: request.target.clone(), + operation: request.operation.clone(), + operation_digest: request.operation_digest.clone(), + requested_scope: request.requested_scope.clone(), + input_digest: request.input_digest.clone(), + }, + } +} + +fn allow_broker() -> CapabilityBroker { + CapabilityBroker::new( + FixedPolicy(PolicyDecision::Allow { + policy_revision: 7, + valid_until_ms: 1_500, + }), + MemoryPermitStore::default(), + ) +} + +fn issued_permit( + broker: &CapabilityBroker, + request: &CapabilityRequest, +) -> cosh_gateway_contracts::capability::ExecutionPermit { + match broker + .authorize(request, &context(request, 1_000)) + .expect("valid request is authorized") + { + CapabilityDecision::Permit { permit } => permit, + decision => panic!("expected permit, got {decision:?}"), + } +} + +fn expectation( + permit: &cosh_gateway_contracts::capability::ExecutionPermit, + now_ms: u64, +) -> PermitExpectation { + PermitExpectation { + permit_id: permit.permit_id.clone(), + actor_id: permit.actor_id.clone(), + task_id: permit.task_id.clone(), + run_id: permit.run_id.clone(), + execution_id: permit.execution_id.clone(), + target: permit.target.clone(), + operation_digest: permit.operation_digest.clone(), + input_digest: permit.input_digest.clone(), + policy_revision: permit.policy_revision, + now_ms, + } +} + +#[test] +fn authorize_fails_closed_for_expiry_and_parent_mismatch() { + let request = request(); + let broker = allow_broker(); + + assert_eq!( + broker.authorize(&request, &context(&request, request.expires_at_ms)), + Err(BrokerError::RequestExpired) + ); + + let mut wrong = context(&request, 1_000); + wrong.parent.task_id = TaskId::new(); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestTaskMismatch) + ); + wrong = context(&request, 1_000); + wrong.parent.run_id = RunId::new(); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestRunMismatch) + ); + wrong = context(&request, 1_000); + wrong.parent.actor.actor_id = ActorId::new(); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestActorMismatch) + ); + wrong = context(&request, 1_000); + wrong.parent.actor.assurance = AuthAssurance::RemoteVerified; + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestActorMismatch) + ); + wrong = context(&request, 1_000); + wrong.parent.actor.issuer = + BoundedName::new("substituted-issuer").expect("test issuer is bounded"); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestActorMismatch) + ); + wrong = context(&request, 1_000); + wrong.binding.target = target("instance-2"); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestContentMismatch) + ); + wrong = context(&request, 1_000); + wrong.binding.operation.name = BoundedName::new("stop").expect("test operation is bounded"); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestContentMismatch) + ); + wrong = context(&request, 1_000); + wrong.binding.operation_digest = digest('d'); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestContentMismatch) + ); + wrong = context(&request, 1_000); + wrong.binding.requested_scope.access = + BoundedName::new("observe").expect("test access is bounded"); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestContentMismatch) + ); + wrong = context(&request, 1_000); + wrong.binding.input_digest = digest('d'); + assert_eq!( + broker.authorize(&request, &wrong), + Err(BrokerError::RequestContentMismatch) + ); +} + +#[test] +fn policy_deny_and_approval_never_issue_a_permit() { + let request = request(); + let deny = CapabilityBroker::new( + FixedPolicy(PolicyDecision::Deny { + code: DenialCode::PolicyDenied, + safe_message: BoundedText::new("blocked by policy").expect("message is bounded"), + }), + MemoryPermitStore::default(), + ); + assert!(matches!( + deny.authorize(&request, &context(&request, 1_000)), + Ok(CapabilityDecision::Deny { + code: DenialCode::PolicyDenied, + .. + }) + )); + + let approval = CapabilityBroker::new( + FixedPolicy(PolicyDecision::RequireApproval { + summary: BoundedText::new("restart host service").expect("message is bounded"), + policy_revision: 7, + valid_until_ms: 1_500, + }), + MemoryPermitStore::default(), + ); + match approval + .authorize(&request, &context(&request, 1_000)) + .expect("approval decision is valid") + { + CapabilityDecision::RequireApproval { approval } => { + assert_eq!(approval.request_id, request.request_id); + assert_eq!(approval.task_id, request.task_id); + assert_eq!(approval.run_id, request.run_id); + assert_eq!(approval.expires_at_ms, 1_500); + } + decision => panic!("expected approval, got {decision:?}"), + } +} + +#[test] +fn unavailable_policy_propagates_without_issuing_authority() { + let request = request(); + let broker = CapabilityBroker::new(UnavailablePolicy, MemoryPermitStore::default()); + assert_eq!( + broker.authorize(&request, &context(&request, 1_000)), + Err(BrokerError::Policy(PolicyError::Unavailable)) + ); +} + +#[test] +fn invalid_policy_authority_fails_closed() { + let request = request(); + let zero_revision = CapabilityBroker::new( + FixedPolicy(PolicyDecision::Allow { + policy_revision: 0, + valid_until_ms: 1_500, + }), + MemoryPermitStore::default(), + ); + assert_eq!( + zero_revision.authorize(&request, &context(&request, 1_000)), + Err(BrokerError::InvalidPolicyRevision) + ); + + let expired = CapabilityBroker::new( + FixedPolicy(PolicyDecision::Allow { + policy_revision: 7, + valid_until_ms: 1_000, + }), + MemoryPermitStore::default(), + ); + assert_eq!( + expired.authorize(&request, &context(&request, 1_000)), + Err(BrokerError::PolicyDecisionExpired) + ); +} + +#[test] +fn issued_permit_binds_every_execution_authority_field() { + let request = request(); + let broker = allow_broker(); + let permit = issued_permit(&broker, &request); + + assert_eq!(permit.actor_id, request.actor.actor_id); + assert_eq!(permit.task_id, request.task_id); + assert_eq!(permit.run_id, request.run_id); + assert_eq!(permit.target, request.target); + assert_eq!(permit.operation_digest, request.operation_digest); + assert_eq!(permit.input_digest, request.input_digest); + assert_eq!(permit.policy_revision, 7); + assert_eq!(permit.valid_until_ms, 1_500); + assert!(permit.single_use); +} + +#[test] +fn wrong_permit_bindings_and_full_operation_digest_fail_without_consuming_authority() { + let request = request(); + let broker = allow_broker(); + let permit = issued_permit(&broker, &request); + let correct = expectation(&permit, 1_100); + + let mut wrong = correct.clone(); + wrong.actor_id = ActorId::new(); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::ActorMismatch)) + ); + wrong = correct.clone(); + wrong.task_id = TaskId::new(); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::TaskMismatch)) + ); + wrong = correct.clone(); + wrong.run_id = RunId::new(); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::RunMismatch)) + ); + wrong = correct.clone(); + wrong.execution_id = ExecutionId::new(); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::ExecutionMismatch)) + ); + wrong = correct.clone(); + wrong.target = target("instance-2"); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::TargetMismatch)) + ); + wrong = correct.clone(); + wrong.operation_digest = digest('d'); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::OperationMismatch)) + ); + wrong = correct.clone(); + wrong.input_digest = digest('d'); + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit(PermitStoreError::InputMismatch)) + ); + wrong = correct.clone(); + wrong.policy_revision += 1; + assert_eq!( + broker.claim(&wrong), + Err(BrokerError::Permit( + PermitStoreError::PolicyRevisionMismatch + )) + ); + + assert_eq!( + broker + .claim(&correct) + .expect("mismatches did not consume permit") + .permit(), + &permit + ); + assert_eq!( + broker.claim(&correct), + Err(BrokerError::Permit(PermitStoreError::AlreadyConsumed)) + ); +} + +#[test] +fn expired_permit_fails_closed() { + let request = request(); + let broker = allow_broker(); + let permit = issued_permit(&broker, &request); + assert_eq!( + broker.claim(&expectation(&permit, permit.valid_until_ms)), + Err(BrokerError::Permit(PermitStoreError::Expired)) + ); +} + +#[test] +fn exactly_one_concurrent_consumer_claims_a_permit() { + let request = request(); + let broker = Arc::new(allow_broker()); + let permit = issued_permit(&broker, &request); + let expected = expectation(&permit, 1_100); + let barrier = Arc::new(Barrier::new(8)); + + let handles: Vec<_> = (0..8) + .map(|_| { + let broker = Arc::clone(&broker); + let expected = expected.clone(); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + broker.claim(&expected) + }) + }) + .collect(); + + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().expect("claim thread does not panic")) + .collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| { + matches!( + result, + Err(BrokerError::Permit(PermitStoreError::AlreadyConsumed)) + ) + }) + .count(), + 7 + ); +} + +#[test] +fn repeated_authorization_replays_the_first_permit() { + let request = request(); + let broker = allow_broker(); + + let first = issued_permit(&broker, &request); + let replayed = issued_permit(&broker, &request); + + assert_eq!(replayed, first); +} + +#[test] +fn concurrent_authorization_issues_one_execution_identity() { + let request = Arc::new(request()); + let broker = Arc::new(allow_broker()); + let barrier = Arc::new(Barrier::new(8)); + let handles: Vec<_> = (0..8) + .map(|_| { + let request = Arc::clone(&request); + let broker = Arc::clone(&broker); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + broker.authorize(&request, &context(&request, 1_000)) + }) + }) + .collect(); + + let decisions = handles + .into_iter() + .map(|handle| handle.join().expect("authorization thread does not panic")) + .collect::, _>>() + .expect("equivalent retries are authorized"); + let permits = decisions + .into_iter() + .map(|decision| match decision { + CapabilityDecision::Permit { permit } => permit, + other => panic!("expected permit, got {other:?}"), + }) + .collect::>(); + + assert!(permits.windows(2).all(|pair| pair[0] == pair[1])); +} + +#[test] +fn reused_request_identity_cannot_change_input_authority() { + let request = request(); + let broker = allow_broker(); + let _ = issued_permit(&broker, &request); + let mut substituted = request.clone(); + substituted.input_digest = digest('d'); + + assert_eq!( + broker.authorize(&substituted, &context(&substituted, 1_000)), + Err(BrokerError::Permit(PermitStoreError::RequestConflict)) + ); +} + +#[test] +fn first_non_permit_decision_is_replayed_across_policy_changes() { + let request = request(); + let permits = MemoryPermitStore::default(); + let approval = CapabilityBroker::new( + FixedPolicy(PolicyDecision::RequireApproval { + summary: BoundedText::new("restart host service").expect("message is bounded"), + policy_revision: 7, + valid_until_ms: 1_500, + }), + permits.clone(), + ); + let first = approval + .authorize(&request, &context(&request, 1_000)) + .expect("approval decision is recorded"); + let allow = CapabilityBroker::new( + FixedPolicy(PolicyDecision::Allow { + policy_revision: 8, + valid_until_ms: 1_600, + }), + permits, + ); + + assert_eq!( + allow + .authorize(&request, &context(&request, 1_000)) + .expect("equivalent retry replays the first decision"), + first + ); + + let mut substituted = request.clone(); + substituted.input_digest = digest('d'); + assert_eq!( + allow.authorize(&substituted, &context(&substituted, 1_000)), + Err(BrokerError::Permit(PermitStoreError::RequestConflict)) + ); +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/lib.rs b/src/cosh-ng/crates/cosh-gateway/src/lib.rs new file mode 100644 index 0000000000..d82885cd08 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/lib.rs @@ -0,0 +1,7 @@ +//! Durable Task coordination, supervised Agent Runtime integration, and +//! fail-closed capability admission foundations. + +pub mod capability; +pub mod runtime; +pub mod storage; +pub mod task; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime.rs new file mode 100644 index 0000000000..2eb0f4a64e --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime.rs @@ -0,0 +1,43 @@ +//! Supervised child-process primitives and private runtime protocol codecs. +//! +//! This module deliberately stops at the process/protocol boundary. Public +//! Task, Run, Agent, and Runtime identities and events belong to +//! `cosh-gateway-contracts` and are mapped by a higher-level bridge. + +mod acp; +mod bounded_io; +mod cosh_core_jsonl; +mod process_group; +mod profile; +mod session_driver; +mod supervisor; + +pub use acp::{ + AcpV1AgentCapabilities, AcpV1AgentInfo, AcpV1BridgeError, AcpV1BridgeRead, AcpV1ClientConfig, + AcpV1Codec, AcpV1CodecError, AcpV1Observation, AcpV1PermissionDecision, AcpV1PermissionOption, + AcpV1PermissionOptionKind, AcpV1PermissionRequest, AcpV1ProtocolPhase, AcpV1RequestId, + AcpV1RequestKind, AcpV1RuntimeBridge, AcpV1StopReason, ACP_WIRE_PROTOCOL_VERSION, +}; +pub use bounded_io::{BoundedLineError, BoundedLineReader, StderrSnapshot}; +pub use cosh_core_jsonl::{ + CoshCoreAssistantBody, CoshCoreAssistantMessage, CoshCoreCapabilities, CoshCoreCodecError, + CoshCoreContentBlock, CoshCoreContentBlockInfo, CoshCoreContentDelta, CoshCoreControlRequest, + CoshCoreControlRequestEnvelope, CoshCoreControlResponse, CoshCoreJsonlCodec, + CoshCoreObservation, CoshCoreProtocolPhase, CoshCoreResult, CoshCoreShellContext, + CoshCoreStreamEvent, CoshCoreSystemMessage, CoshCoreToolResult, CoshCoreUserTurn, + PRIVATE_COSH_CONTROL_PROTOCOL_VERSION, +}; +pub use process_group::{PlatformProcessGroup, ProcessGroupLifecycle}; +pub use profile::{ + built_in_acp_runtime_profiles, AcpRuntimeProfile, AcpRuntimeProfileId, + AcpRuntimeProfileLaunchError, AcpRuntimeProfileRequest, AcpRuntimeProfileResolveError, + AcpRuntimeProfileResolver, ResolvedAcpRuntimeProfile, +}; +pub use session_driver::{ + AcpSessionControl, AcpSessionDriver, AcpSessionDriverConfig, AcpSessionDriverError, + AcpSessionEvent, AcpSessionTerminal, AcpSessionTerminalKind, +}; +pub use supervisor::{ + ProcessExit, ProcessTerminal, RuntimeFrameRead, RuntimeLaunchError, RuntimeLaunchSpec, + RuntimeState, RuntimeSupervisor, RuntimeSupervisorError, +}; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/acp.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp.rs new file mode 100644 index 0000000000..3bc3728cc3 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp.rs @@ -0,0 +1,20 @@ +//! ACP v1 client codec and bridge over the supervised local runtime transport. +//! +//! The official Rust SDK version and the negotiated wire version are separate +//! contracts. This module uses SDK 2.0 types while sending ACP wire version 1. + +mod bridge; +mod codec; +mod types; + +#[cfg(test)] +mod tests; + +pub use bridge::{AcpV1BridgeError, AcpV1BridgeRead, AcpV1RuntimeBridge}; +pub use codec::AcpV1Codec; +pub use types::{ + AcpV1AgentCapabilities, AcpV1AgentInfo, AcpV1ClientConfig, AcpV1CodecError, AcpV1Observation, + AcpV1PermissionDecision, AcpV1PermissionOption, AcpV1PermissionOptionKind, + AcpV1PermissionRequest, AcpV1ProtocolPhase, AcpV1RequestId, AcpV1RequestKind, AcpV1StopReason, + ACP_WIRE_PROTOCOL_VERSION, +}; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/bridge.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/bridge.rs new file mode 100644 index 0000000000..5fdfacf3d9 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/bridge.rs @@ -0,0 +1,296 @@ +//! Minimal synchronous ACP client bridge over [`RuntimeSupervisor`]. + +use std::path::PathBuf; +use std::time::Duration; + +use thiserror::Error; + +use super::codec::AcpV1Codec; +use super::types::{ + AcpV1ClientConfig, AcpV1CodecError, AcpV1Observation, AcpV1PermissionDecision, + AcpV1ProtocolPhase, AcpV1RequestId, +}; +use crate::runtime::{ + ProcessTerminal, RuntimeFrameRead, RuntimeLaunchSpec, RuntimeState, RuntimeSupervisor, + RuntimeSupervisorError, +}; + +const PROTOCOL_FAILURE_SHUTDOWN_GRACE: Duration = Duration::from_millis(100); + +/// Failure returned by the supervised ACP v1 bridge. +#[derive(Debug, Error)] +pub enum AcpV1BridgeError { + /// Caller or codec state did not permit the requested operation. + #[error(transparent)] + Codec(#[from] AcpV1CodecError), + /// Process supervision or bounded I/O failed. + #[error(transparent)] + Supervisor(#[from] RuntimeSupervisorError), + /// Invalid ACP input was detected and process cleanup also failed. + #[error("ACP protocol failed: {protocol}; runtime cleanup also failed: {cleanup}")] + ProtocolCleanup { + /// Original fail-closed protocol error. + protocol: AcpV1CodecError, + /// Cleanup failure after the protocol was made terminal. + cleanup: RuntimeSupervisorError, + }, + /// Runtime transport failed and process cleanup also failed. + #[error("ACP transport failed: {transport}; runtime cleanup also failed: {cleanup}")] + TransportCleanup { + /// Original fail-closed transport error. + transport: RuntimeSupervisorError, + /// Cleanup failure after the codec was made terminal. + cleanup: RuntimeSupervisorError, + }, +} + +/// Outcome of waiting for one ACP observation with a deadline. +#[derive(Debug, Clone, PartialEq)] +pub enum AcpV1BridgeRead { + /// One validated ACP observation was received. + Observation(AcpV1Observation), + /// No Agent frame arrived within the requested duration. + TimedOut, +} + +/// Owns one ACP codec and the sole supervisor for its Agent subprocess. +#[derive(Debug)] +pub struct AcpV1RuntimeBridge { + codec: AcpV1Codec, + supervisor: RuntimeSupervisor, + terminal: Option, +} + +impl AcpV1RuntimeBridge { + /// Validates configuration and launches one ACP Agent subprocess. + /// + /// The launch uses the existing hardened supervisor: no shell expansion, + /// an explicit cleared environment, pinned cwd, bounded stdout lines, and + /// process-group cleanup remain in force. + /// + /// # Errors + /// + /// Returns client configuration validation or process launch failures. + pub fn launch( + spec: &RuntimeLaunchSpec, + config: AcpV1ClientConfig, + ) -> Result { + let codec = AcpV1Codec::new(config)?; + let mut supervisor = RuntimeSupervisor::new(); + supervisor.launch(spec)?; + Ok(Self { + codec, + supervisor, + terminal: None, + }) + } + + /// Returns the negotiated codec phase. + #[must_use] + pub fn protocol_phase(&self) -> AcpV1ProtocolPhase { + self.codec.phase() + } + + /// Returns the supervised process lifecycle state. + #[must_use] + pub fn runtime_state(&self) -> RuntimeState { + self.supervisor.state() + } + + /// Returns the bound opaque ACP session identifier, when available. + #[must_use] + pub fn session_id(&self) -> Option<&str> { + self.codec.session_id() + } + + /// Sends the mandatory ACP v1 initialize request. + /// + /// # Errors + /// + /// Returns codec state, frame bound, or runtime pipe failures. + pub fn send_initialize(&mut self) -> Result<(), AcpV1BridgeError> { + self.commit_frame(AcpV1Codec::initialize_frame) + } + + /// Sends `session/new` after successful initialization. + /// + /// # Errors + /// + /// Returns workspace, capability, codec state, frame bound, or runtime + /// pipe failures. + pub fn send_new_session( + &mut self, + workspace: impl Into, + additional_directories: Vec, + ) -> Result<(), AcpV1BridgeError> { + let workspace = workspace.into(); + self.commit_frame(move |codec| codec.new_session_frame(workspace, additional_directories)) + } + + /// Sends one text-only `session/prompt` request. + /// + /// # Errors + /// + /// Returns codec state, frame bound, or runtime pipe failures. + pub fn send_prompt(&mut self, text: impl Into) -> Result<(), AcpV1BridgeError> { + let text = text.into(); + self.commit_frame(move |codec| codec.prompt_frame(text)) + } + + /// Sends session cancellation plus mandatory cancelled permission replies. + /// + /// # Errors + /// + /// Returns codec state, frame bound, or runtime pipe failures. Frames are + /// written in their required order and the first write failure is returned. + pub fn send_cancel(&mut self) -> Result<(), AcpV1BridgeError> { + let mut candidate = self.codec.clone(); + let frames = candidate.cancel_frames()?; + for frame in frames { + if let Err(transport) = self.supervisor.write_frame(&frame) { + return Err(self.fail_transport(transport)); + } + } + self.codec = candidate; + Ok(()) + } + + /// Sends one governed permission decision to the Agent. + /// + /// # Errors + /// + /// Rejects unknown correlations/options and runtime pipe failures. + pub fn send_permission_decision( + &mut self, + request_id: &AcpV1RequestId, + decision: AcpV1PermissionDecision, + ) -> Result<(), AcpV1BridgeError> { + let request_id = request_id.clone(); + self.commit_frame(move |codec| codec.permission_response_frame(&request_id, decision)) + } + + /// Sends method-not-found for an unadvertised Agent callback. + /// + /// # Errors + /// + /// Rejects unknown correlations and runtime pipe failures. + pub fn reject_unsupported_request( + &mut self, + request_id: &AcpV1RequestId, + ) -> Result<(), AcpV1BridgeError> { + let request_id = request_id.clone(); + self.commit_frame(move |codec| codec.reject_unsupported_request_frame(&request_id)) + } + + /// Reads and validates the next bounded Agent frame. + /// + /// A successful initialization atomically moves the supervisor from + /// `Initializing` to `Ready`. Any invalid ACP frame makes the codec + /// terminal and terminates the entire supervised process group. + /// + /// # Errors + /// + /// Returns bounded I/O, protocol validation, or cleanup failures. + pub fn read_observation(&mut self) -> Result, AcpV1BridgeError> { + loop { + match self.read_observation_timeout(Duration::from_secs(60))? { + AcpV1BridgeRead::Observation(observation) => return Ok(Some(observation)), + AcpV1BridgeRead::TimedOut => {} + } + } + } + + /// Waits at most `timeout` for one validated Agent observation. + /// + /// # Errors + /// + /// Returns bounded I/O, protocol validation, or cleanup failures. + pub fn read_observation_timeout( + &mut self, + timeout: Duration, + ) -> Result { + let frame = match self.supervisor.read_frame_timeout(timeout) { + Ok(RuntimeFrameRead::Frame(frame)) => frame, + Ok(RuntimeFrameRead::Eof) => { + let observation = self.codec.finish_stdout(); + self.terminal = self.supervisor.shutdown(PROTOCOL_FAILURE_SHUTDOWN_GRACE)?; + return Ok(AcpV1BridgeRead::Observation( + observation.unwrap_or(AcpV1Observation::TransportClosed), + )); + } + Ok(RuntimeFrameRead::TimedOut) => return Ok(AcpV1BridgeRead::TimedOut), + Err(transport) => return Err(self.fail_transport(transport)), + }; + let observation = match self.codec.decode_frame(frame.as_bytes()) { + Ok(observation) => observation, + Err(protocol) => return Err(self.fail_protocol(protocol)), + }; + if matches!(observation, AcpV1Observation::Initialized { .. }) { + if let Err(transport) = self.supervisor.mark_ready() { + return Err(self.fail_transport(transport)); + } + } + Ok(AcpV1BridgeRead::Observation(observation)) + } + + /// Polls the underlying process terminal without blocking. + /// + /// # Errors + /// + /// Returns invalid lifecycle state or OS wait failures. + pub fn poll_terminal(&mut self) -> Result, AcpV1BridgeError> { + if self.terminal.is_some() { + return Ok(self.terminal.take()); + } + self.supervisor.poll_terminal().map_err(Into::into) + } + + /// Terminates the Agent process group and reaps the child. + /// + /// # Errors + /// + /// Returns invalid lifecycle, signalling, or wait failures. + pub fn shutdown( + &mut self, + grace: Duration, + ) -> Result, AcpV1BridgeError> { + if self.terminal.is_some() { + return Ok(self.terminal.take()); + } + self.supervisor.shutdown(grace).map_err(Into::into) + } + + fn fail_protocol(&mut self, protocol: AcpV1CodecError) -> AcpV1BridgeError { + match self.supervisor.shutdown(PROTOCOL_FAILURE_SHUTDOWN_GRACE) { + Ok(terminal) => { + self.terminal = terminal; + AcpV1BridgeError::Codec(protocol) + } + Err(cleanup) => AcpV1BridgeError::ProtocolCleanup { protocol, cleanup }, + } + } + + fn fail_transport(&mut self, transport: RuntimeSupervisorError) -> AcpV1BridgeError { + let _ = self.codec.finish_stdout(); + match self.supervisor.shutdown(PROTOCOL_FAILURE_SHUTDOWN_GRACE) { + Ok(terminal) => { + self.terminal = terminal; + AcpV1BridgeError::Supervisor(transport) + } + Err(cleanup) => AcpV1BridgeError::TransportCleanup { transport, cleanup }, + } + } + + fn commit_frame( + &mut self, + encode: impl FnOnce(&mut AcpV1Codec) -> Result, + ) -> Result<(), AcpV1BridgeError> { + let mut candidate = self.codec.clone(); + let frame = encode(&mut candidate)?; + if let Err(transport) = self.supervisor.write_frame(&frame) { + return Err(self.fail_transport(transport)); + } + self.codec = candidate; + Ok(()) + } +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/codec.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/codec.rs new file mode 100644 index 0000000000..64ba055912 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/codec.rs @@ -0,0 +1,725 @@ +//! Stateful ACP v1 JSON-RPC codec built from official SDK wire types. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use agent_client_protocol::schema::{ + v1::{ + AgentCapabilities, CancelNotification, ClientNotification, ClientRequest, ContentBlock, + Error as AcpError, Implementation, InitializeRequest, InitializeResponse, + NewSessionRequest, NewSessionResponse, PermissionOptionKind, PromptRequest, PromptResponse, + RequestId, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + Response, SelectedPermissionOutcome, SessionNotification, StopReason, TextContent, + CLIENT_METHOD_NAMES, + }, + ProtocolVersion, +}; +use agent_client_protocol::{RawJsonRpcMessage, RawJsonRpcParams}; + +use super::types::{ + AcpV1AgentCapabilities, AcpV1AgentInfo, AcpV1ClientConfig, AcpV1CodecError, AcpV1Observation, + AcpV1PermissionDecision, AcpV1PermissionOption, AcpV1PermissionOptionKind, + AcpV1PermissionRequest, AcpV1ProtocolPhase, AcpV1RequestId, AcpV1RequestKind, AcpV1StopReason, + ACP_WIRE_PROTOCOL_VERSION, +}; + +const MAX_ACP_FRAME_BYTES: usize = 1024 * 1024; +const MAX_PENDING_CLIENT_REQUESTS: usize = 64; + +#[derive(Debug, Clone)] +struct PendingOutboundRequest { + kind: AcpV1RequestKind, + session_id: Option, +} + +#[derive(Debug, Clone)] +struct PendingPermission { + option_ids: BTreeMap, +} + +/// Stateful encoder and decoder for one ACP v1 process generation. +#[derive(Debug, Clone)] +pub struct AcpV1Codec { + config: AcpV1ClientConfig, + phase: AcpV1ProtocolPhase, + next_request_sequence: u64, + pending_outbound: BTreeMap, + pending_permissions: BTreeMap, + pending_unsupported: BTreeSet, + capabilities: Option, + session_id: Option, + prompt_request_id: Option, + cancellation_sent: bool, +} + +impl AcpV1Codec { + /// Creates one codec with an explicit client identity and frame bound. + /// + /// # Errors + /// + /// Rejects empty implementation metadata and a zero frame bound. + pub fn new(config: AcpV1ClientConfig) -> Result { + if config.max_frame_bytes == 0 || config.max_frame_bytes > MAX_ACP_FRAME_BYTES { + return Err(AcpV1CodecError::InvalidFrameLimit { + actual: config.max_frame_bytes, + maximum: MAX_ACP_FRAME_BYTES, + }); + } + if config.name.trim().is_empty() { + return Err(AcpV1CodecError::InvalidClientInfo { field: "name" }); + } + if config.version.trim().is_empty() { + return Err(AcpV1CodecError::InvalidClientInfo { field: "version" }); + } + Ok(Self { + config, + phase: AcpV1ProtocolPhase::Created, + next_request_sequence: 1, + pending_outbound: BTreeMap::new(), + pending_permissions: BTreeMap::new(), + pending_unsupported: BTreeSet::new(), + capabilities: None, + session_id: None, + prompt_request_id: None, + cancellation_sent: false, + }) + } + + /// Returns the current ACP protocol phase. + #[must_use] + pub fn phase(&self) -> AcpV1ProtocolPhase { + self.phase + } + + /// Returns the opaque session bound after `session/new` succeeds. + #[must_use] + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// Encodes the mandatory ACP v1 initialize request. + /// + /// # Errors + /// + /// Rejects repeated initialization and frames above the configured bound. + pub fn initialize_frame(&mut self) -> Result { + self.require_phase(AcpV1ProtocolPhase::Created, "initialize_frame")?; + let request = InitializeRequest::new(ProtocolVersion::V1).client_info(Implementation::new( + self.config.name.clone(), + self.config.version.clone(), + )); + let (id, frame) = self.encode_request(ClientRequest::InitializeRequest(request))?; + self.pending_outbound.insert( + id, + PendingOutboundRequest { + kind: AcpV1RequestKind::Initialize, + session_id: None, + }, + ); + self.phase = AcpV1ProtocolPhase::AwaitingInitialize; + Ok(frame) + } + + /// Encodes `session/new` for one pinned workspace. + /// + /// # Errors + /// + /// Requires successful initialization, absolute roots, no existing + /// session, and advertised additional-directory support when used. + pub fn new_session_frame( + &mut self, + workspace: impl Into, + additional_directories: Vec, + ) -> Result { + self.require_phase(AcpV1ProtocolPhase::Ready, "new_session_frame")?; + if self.session_id.is_some() + || self + .pending_outbound + .values() + .any(|pending| pending.kind == AcpV1RequestKind::NewSession) + { + return Err(AcpV1CodecError::SessionAlreadyBound); + } + let workspace = workspace.into(); + validate_absolute(&workspace)?; + for directory in &additional_directories { + validate_absolute(directory)?; + } + if !additional_directories.is_empty() + && !self + .capabilities + .is_some_and(|capabilities| capabilities.additional_directories) + { + return Err(AcpV1CodecError::UnsupportedCapability( + "session.additionalDirectories", + )); + } + + let request = + NewSessionRequest::new(workspace).additional_directories(additional_directories); + let (id, frame) = self.encode_request(ClientRequest::NewSessionRequest(request))?; + self.pending_outbound.insert( + id, + PendingOutboundRequest { + kind: AcpV1RequestKind::NewSession, + session_id: None, + }, + ); + Ok(frame) + } + + /// Encodes one text-only prompt in the bound Agent session. + /// + /// # Errors + /// + /// Requires an open session, non-empty text, and no active prompt. + pub fn prompt_frame(&mut self, text: impl Into) -> Result { + self.require_phase(AcpV1ProtocolPhase::Ready, "prompt_frame")?; + let session_id = self + .session_id + .clone() + .ok_or(AcpV1CodecError::SessionNotOpen)?; + if self.prompt_request_id.is_some() { + return Err(AcpV1CodecError::PromptAlreadyActive); + } + let text = text.into(); + if text.trim().is_empty() { + return Err(AcpV1CodecError::EmptyPrompt); + } + let request = PromptRequest::new( + session_id.clone(), + vec![ContentBlock::Text(TextContent::new(text))], + ); + let (id, frame) = self.encode_request(ClientRequest::PromptRequest(request))?; + self.pending_outbound.insert( + id.clone(), + PendingOutboundRequest { + kind: AcpV1RequestKind::Prompt, + session_id: Some(session_id), + }, + ); + self.prompt_request_id = Some(id); + self.cancellation_sent = false; + Ok(frame) + } + + /// Encodes `session/cancel` and cancels every pending permission callback. + /// + /// The first frame is the cancellation notification. Remaining frames are + /// mandatory `cancelled` responses for outstanding permission requests. + /// + /// # Errors + /// + /// Requires an active prompt and rejects duplicate cancellation. + pub fn cancel_frames(&mut self) -> Result, AcpV1CodecError> { + self.require_phase(AcpV1ProtocolPhase::Ready, "cancel_frames")?; + if self.prompt_request_id.is_none() { + return Err(AcpV1CodecError::PromptNotActive); + } + if self.cancellation_sent { + return Err(AcpV1CodecError::CancellationAlreadySent); + } + let session_id = self + .session_id + .clone() + .ok_or(AcpV1CodecError::SessionNotOpen)?; + let notification = + ClientNotification::CancelNotification(CancelNotification::new(session_id)); + let mut frames = vec![self.encode_notification(notification)?]; + for request_id in self.pending_permissions.keys() { + frames.push( + self.encode_permission_outcome(request_id, RequestPermissionOutcome::Cancelled)?, + ); + } + for request_id in &self.pending_unsupported { + let raw = RawJsonRpcMessage::response( + to_sdk_request_id(request_id), + Err(AcpError::method_not_found()), + ); + frames.push(self.encode_raw(&raw)?); + } + self.pending_permissions.clear(); + self.pending_unsupported.clear(); + self.cancellation_sent = true; + Ok(frames) + } + + /// Encodes a response to one pending permission callback. + /// + /// # Errors + /// + /// Rejects unknown requests and selected option IDs not offered by the + /// correlated Agent request. + pub fn permission_response_frame( + &mut self, + request_id: &AcpV1RequestId, + decision: AcpV1PermissionDecision, + ) -> Result { + self.require_phase(AcpV1ProtocolPhase::Ready, "permission_response_frame")?; + let pending = self + .pending_permissions + .get(request_id) + .ok_or_else(|| AcpV1CodecError::UnknownPermissionRequest(request_id.clone()))?; + let outcome = match decision { + AcpV1PermissionDecision::Cancelled => RequestPermissionOutcome::Cancelled, + AcpV1PermissionDecision::Selected { option_id } => { + let Some(kind) = pending.option_ids.get(&option_id) else { + return Err(AcpV1CodecError::UnknownPermissionOption { + request_id: request_id.clone(), + option_id, + }); + }; + if !matches!( + kind, + AcpV1PermissionOptionKind::AllowOnce | AcpV1PermissionOptionKind::RejectOnce + ) { + return Err(AcpV1CodecError::UnsupportedPermissionOption { + request_id: request_id.clone(), + option_id, + }); + } + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option_id)) + } + }; + let frame = self.encode_permission_outcome(request_id, outcome)?; + self.pending_permissions.remove(request_id); + Ok(frame) + } + + /// Encodes a fail-closed method-not-found response for an unadvertised callback. + /// + /// # Errors + /// + /// Rejects request IDs that were not returned by + /// [`AcpV1Observation::UnsupportedClientRequest`]. + pub fn reject_unsupported_request_frame( + &mut self, + request_id: &AcpV1RequestId, + ) -> Result { + self.require_phase( + AcpV1ProtocolPhase::Ready, + "reject_unsupported_request_frame", + )?; + if !self.pending_unsupported.contains(request_id) { + return Err(AcpV1CodecError::UnknownUnsupportedRequest( + request_id.clone(), + )); + } + let raw = RawJsonRpcMessage::response( + to_sdk_request_id(request_id), + Err(AcpError::method_not_found()), + ); + let frame = self.encode_raw(&raw)?; + self.pending_unsupported.remove(request_id); + Ok(frame) + } + + /// Decodes and validates one bounded ACP v1 JSON-RPC line. + /// + /// # Errors + /// + /// Rejects malformed frames, wrong ordering, identity mismatches, unknown + /// responses, and invalid callback correlations. Any error makes the codec + /// terminal so the supervising bridge can fail closed. + pub fn decode_frame(&mut self, frame: &[u8]) -> Result { + if self.phase == AcpV1ProtocolPhase::Terminal { + return Err(self.invalid_phase("decode_frame")); + } + let result = self.decode_frame_inner(frame); + if result.is_err() { + self.phase = AcpV1ProtocolPhase::Terminal; + } + result + } + + /// Produces one terminal observation when runtime stdout closes. + #[must_use] + pub fn finish_stdout(&mut self) -> Option { + if self.phase == AcpV1ProtocolPhase::Terminal { + return None; + } + self.phase = AcpV1ProtocolPhase::Terminal; + Some(AcpV1Observation::TransportClosed) + } + + fn decode_frame_inner(&mut self, frame: &[u8]) -> Result { + if frame.len() > self.config.max_frame_bytes { + return Err(AcpV1CodecError::FrameTooLarge { + limit: self.config.max_frame_bytes, + }); + } + let frame = std::str::from_utf8(frame).map_err(|_| AcpV1CodecError::InvalidUtf8)?; + let frame = frame.trim_end_matches(['\r', '\n']); + if frame.is_empty() { + return Err(AcpV1CodecError::EmptyFrame); + } + if self.phase == AcpV1ProtocolPhase::Created { + return Err(self.invalid_phase("decode_frame")); + } + + let message = serde_json::from_str::(frame)?; + match message { + RawJsonRpcMessage::Response(response) => self.decode_response(response), + RawJsonRpcMessage::Notification(notification) => { + self.require_phase(AcpV1ProtocolPhase::Ready, "decode_notification")?; + self.decode_notification(notification.method.as_ref(), notification.params) + } + RawJsonRpcMessage::Request(request) => { + self.require_phase(AcpV1ProtocolPhase::Ready, "decode_request")?; + self.decode_request(request.id, request.method.as_ref(), request.params) + } + } + } + + fn decode_response( + &mut self, + response: Response, + ) -> Result { + match response { + Response::Result { id, result } => { + let request_id = from_sdk_request_id(id)?; + let pending = self + .pending_outbound + .remove(&request_id) + .ok_or_else(|| AcpV1CodecError::UnknownResponse(request_id.clone()))?; + self.decode_success(pending, result) + } + Response::Error { id, error } => { + let request_id = from_sdk_request_id(id)?; + let pending = self + .pending_outbound + .remove(&request_id) + .ok_or_else(|| AcpV1CodecError::UnknownResponse(request_id.clone()))?; + if pending.kind == AcpV1RequestKind::Prompt { + self.prompt_request_id = None; + self.cancellation_sent = false; + } + if pending.kind == AcpV1RequestKind::Initialize { + self.phase = AcpV1ProtocolPhase::Terminal; + } + Ok(AcpV1Observation::RequestFailed { + request: pending.kind, + code: i32::from(error.code), + message: error.message, + }) + } + } + } + + fn decode_success( + &mut self, + pending: PendingOutboundRequest, + result: serde_json::Value, + ) -> Result { + match pending.kind { + AcpV1RequestKind::Initialize => self.decode_initialize_response(result), + AcpV1RequestKind::NewSession => self.decode_new_session_response(result), + AcpV1RequestKind::Prompt => self.decode_prompt_response(pending, result), + } + } + + fn decode_initialize_response( + &mut self, + result: serde_json::Value, + ) -> Result { + let response: InitializeResponse = serde_json::from_value(result)?; + if response.protocol_version != ProtocolVersion::V1 { + return Err(AcpV1CodecError::UnsupportedProtocolVersion { + actual: response.protocol_version.as_u16(), + }); + } + let capabilities = copy_capabilities(&response.agent_capabilities); + let agent_info = response.agent_info.map(|info| AcpV1AgentInfo { + name: info.name, + title: info.title, + version: info.version, + }); + self.capabilities = Some(capabilities); + self.phase = AcpV1ProtocolPhase::Ready; + Ok(AcpV1Observation::Initialized { + agent_info, + capabilities, + }) + } + + fn decode_new_session_response( + &mut self, + result: serde_json::Value, + ) -> Result { + let response: NewSessionResponse = serde_json::from_value(result)?; + let session_id = response.session_id.0.to_string(); + if self.session_id.replace(session_id.clone()).is_some() { + return Err(AcpV1CodecError::SessionAlreadyBound); + } + Ok(AcpV1Observation::SessionOpened { session_id }) + } + + fn decode_prompt_response( + &mut self, + pending: PendingOutboundRequest, + result: serde_json::Value, + ) -> Result { + let response: PromptResponse = serde_json::from_value(result)?; + if !self.pending_permissions.is_empty() { + return Err(AcpV1CodecError::PromptFinishedWithPendingPermissions { + count: self.pending_permissions.len(), + }); + } + if !self.pending_unsupported.is_empty() { + return Err(AcpV1CodecError::PromptFinishedWithPendingUnsupported { + count: self.pending_unsupported.len(), + }); + } + let session_id = pending.session_id.ok_or(AcpV1CodecError::SessionNotOpen)?; + self.require_session(&session_id)?; + self.prompt_request_id = None; + self.cancellation_sent = false; + self.pending_permissions.clear(); + Ok(AcpV1Observation::PromptFinished { + session_id, + stop_reason: copy_stop_reason(response.stop_reason), + }) + } + + fn decode_notification( + &mut self, + method: &str, + params: Option, + ) -> Result { + if method != CLIENT_METHOD_NAMES.session_update { + return Ok(AcpV1Observation::UnsupportedNotification { + method: method.to_owned(), + }); + } + let notification: SessionNotification = decode_params(params)?; + let session_id = notification.session_id.0.to_string(); + self.require_session(&session_id)?; + if self.prompt_request_id.is_none() { + return Err(AcpV1CodecError::PromptNotActive); + } + if self.cancellation_sent { + return Err(AcpV1CodecError::CancellationAlreadySent); + } + let update = serde_json::to_value(notification.update)?; + Ok(AcpV1Observation::SessionUpdate { session_id, update }) + } + + fn decode_request( + &mut self, + id: RequestId, + method: &str, + params: Option, + ) -> Result { + let request_id = from_sdk_request_id(id)?; + if self.pending_permissions.contains_key(&request_id) + || self.pending_unsupported.contains(&request_id) + { + return Err(AcpV1CodecError::DuplicateInboundRequest(request_id)); + } + if self.pending_permissions.len() + self.pending_unsupported.len() + >= MAX_PENDING_CLIENT_REQUESTS + { + return Err(AcpV1CodecError::TooManyPendingClientRequests { + limit: MAX_PENDING_CLIENT_REQUESTS, + }); + } + if method != CLIENT_METHOD_NAMES.session_request_permission { + self.pending_unsupported.insert(request_id.clone()); + return Ok(AcpV1Observation::UnsupportedClientRequest { + request_id, + method: method.to_owned(), + }); + } + if self.prompt_request_id.is_none() { + return Err(AcpV1CodecError::PromptNotActive); + } + if self.cancellation_sent { + return Err(AcpV1CodecError::CancellationAlreadySent); + } + let request: RequestPermissionRequest = decode_params(params)?; + let session_id = request.session_id.0.to_string(); + self.require_session(&session_id)?; + if request.options.is_empty() { + return Err(AcpV1CodecError::EmptyPermissionOptions); + } + let mut option_ids = BTreeMap::new(); + let mut options = Vec::with_capacity(request.options.len()); + for option in request.options { + let option_id = option.option_id.0.to_string(); + let kind = copy_permission_kind(option.kind); + if option_ids.insert(option_id.clone(), kind).is_some() { + return Err(AcpV1CodecError::DuplicatePermissionOption(option_id)); + } + options.push(AcpV1PermissionOption { + option_id, + name: option.name, + kind, + }); + } + let tool_call = serde_json::to_value(request.tool_call)?; + self.pending_permissions + .insert(request_id.clone(), PendingPermission { option_ids }); + Ok(AcpV1Observation::PermissionRequested( + AcpV1PermissionRequest { + request_id, + session_id, + tool_call, + options, + }, + )) + } + + fn encode_request( + &mut self, + request: ClientRequest, + ) -> Result<(AcpV1RequestId, String), AcpV1CodecError> { + let method = request.method().to_owned(); + let params = serde_json::to_value(request)?; + let request_id = self.next_request_id()?; + let raw = RawJsonRpcMessage::request(method, params, to_sdk_request_id(&request_id)) + .map_err(|error| AcpV1CodecError::Sdk(error.to_string()))?; + let frame = self.encode_raw(&raw)?; + Ok((request_id, frame)) + } + + fn encode_notification( + &self, + notification: ClientNotification, + ) -> Result { + let method = notification.method().to_owned(); + let params = serde_json::to_value(notification)?; + let raw = RawJsonRpcMessage::notification(method, params) + .map_err(|error| AcpV1CodecError::Sdk(error.to_string()))?; + self.encode_raw(&raw) + } + + fn encode_permission_outcome( + &self, + request_id: &AcpV1RequestId, + outcome: RequestPermissionOutcome, + ) -> Result { + let response = RequestPermissionResponse::new(outcome); + let result = serde_json::to_value(response)?; + let raw = RawJsonRpcMessage::response(to_sdk_request_id(request_id), Ok(result)); + self.encode_raw(&raw) + } + + fn encode_raw(&self, raw: &RawJsonRpcMessage) -> Result { + let frame = serde_json::to_string(raw)?; + if frame.len() > self.config.max_frame_bytes { + return Err(AcpV1CodecError::FrameTooLarge { + limit: self.config.max_frame_bytes, + }); + } + Ok(frame) + } + + fn next_request_id(&mut self) -> Result { + let sequence = self.next_request_sequence; + self.next_request_sequence = sequence + .checked_add(1) + .ok_or(AcpV1CodecError::RequestIdExhausted)?; + Ok(AcpV1RequestId::String(format!("cosh-acp-{sequence}"))) + } + + fn require_phase( + &self, + expected: AcpV1ProtocolPhase, + operation: &'static str, + ) -> Result<(), AcpV1CodecError> { + if self.phase != expected { + return Err(self.invalid_phase(operation)); + } + Ok(()) + } + + fn require_session(&self, actual: &str) -> Result<(), AcpV1CodecError> { + let expected = self + .session_id + .as_deref() + .ok_or(AcpV1CodecError::SessionNotOpen)?; + if expected != actual { + return Err(AcpV1CodecError::SessionMismatch { + expected: expected.to_owned(), + actual: actual.to_owned(), + }); + } + Ok(()) + } + + fn invalid_phase(&self, operation: &'static str) -> AcpV1CodecError { + AcpV1CodecError::InvalidPhase { + operation, + phase: self.phase, + } + } +} + +fn validate_absolute(path: &Path) -> Result<(), AcpV1CodecError> { + if !path.is_absolute() { + return Err(AcpV1CodecError::WorkspaceNotAbsolute(path.to_path_buf())); + } + Ok(()) +} + +fn decode_params( + params: Option, +) -> Result { + let value = params.map_or(serde_json::Value::Null, RawJsonRpcParams::into_value); + serde_json::from_value(value).map_err(Into::into) +} + +fn from_sdk_request_id(id: RequestId) -> Result { + match id { + RequestId::Null => Err(AcpV1CodecError::NullRequestId), + RequestId::Number(value) => Ok(AcpV1RequestId::Number(value)), + RequestId::Str(value) => Ok(AcpV1RequestId::String(value)), + } +} + +fn to_sdk_request_id(id: &AcpV1RequestId) -> RequestId { + match id { + AcpV1RequestId::Number(value) => RequestId::Number(*value), + AcpV1RequestId::String(value) => RequestId::Str(value.clone()), + } +} + +fn copy_capabilities(capabilities: &AgentCapabilities) -> AcpV1AgentCapabilities { + AcpV1AgentCapabilities { + load_session: capabilities.load_session, + list_sessions: capabilities.session_capabilities.list.is_some(), + delete_session: capabilities.session_capabilities.delete.is_some(), + additional_directories: capabilities + .session_capabilities + .additional_directories + .is_some(), + resume_session: capabilities.session_capabilities.resume.is_some(), + close_session: capabilities.session_capabilities.close.is_some(), + image_prompts: capabilities.prompt_capabilities.image, + audio_prompts: capabilities.prompt_capabilities.audio, + embedded_context: capabilities.prompt_capabilities.embedded_context, + } +} + +fn copy_stop_reason(reason: StopReason) -> AcpV1StopReason { + match reason { + StopReason::EndTurn => AcpV1StopReason::EndTurn, + StopReason::MaxTokens => AcpV1StopReason::MaxTokens, + StopReason::MaxTurnRequests => AcpV1StopReason::MaxTurnRequests, + StopReason::Refusal => AcpV1StopReason::Refusal, + StopReason::Cancelled => AcpV1StopReason::Cancelled, + _ => AcpV1StopReason::Unsupported, + } +} + +fn copy_permission_kind(kind: PermissionOptionKind) -> AcpV1PermissionOptionKind { + match kind { + PermissionOptionKind::AllowOnce => AcpV1PermissionOptionKind::AllowOnce, + PermissionOptionKind::AllowAlways => AcpV1PermissionOptionKind::AllowAlways, + PermissionOptionKind::RejectOnce => AcpV1PermissionOptionKind::RejectOnce, + PermissionOptionKind::RejectAlways => AcpV1PermissionOptionKind::RejectAlways, + _ => AcpV1PermissionOptionKind::Unsupported, + } +} + +const _: () = assert!(ProtocolVersion::V1.as_u16() == ACP_WIRE_PROTOCOL_VERSION); diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/tests.rs new file mode 100644 index 0000000000..55be174705 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/tests.rs @@ -0,0 +1,583 @@ +//! Focused ACP v1 codec and supervised stdio bridge tests. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::{json, Value}; + +use super::*; +use crate::runtime::{RuntimeLaunchSpec, RuntimeState}; + +const FRAME_LIMIT: usize = 16 * 1024; +const SESSION_ID: &str = "agent-session-1"; + +fn codec() -> AcpV1Codec { + AcpV1Codec::new(AcpV1ClientConfig::new("cosh-ng", "0.15.0", FRAME_LIMIT)).unwrap() +} + +fn initialize(codec: &mut AcpV1Codec, capabilities: Value) -> AcpV1Observation { + let request = codec.initialize_frame().unwrap(); + let value: Value = serde_json::from_str(&request).unwrap(); + assert_eq!(value["jsonrpc"], "2.0"); + assert_eq!(value["id"], "cosh-acp-1"); + assert_eq!(value["method"], "initialize"); + assert_eq!(value["params"]["protocolVersion"], 1); + assert_eq!(value["params"]["clientInfo"]["name"], "cosh-ng"); + assert_eq!(value["params"]["clientInfo"]["version"], "0.15.0"); + + let response = json!({ + "jsonrpc": "2.0", + "id": "cosh-acp-1", + "result": { + "protocolVersion": 1, + "agentCapabilities": capabilities, + "agentInfo": { + "name": "fake-agent", + "title": "Fake Agent", + "version": "1.2.3" + } + } + }); + codec.decode_frame(response.to_string().as_bytes()).unwrap() +} + +fn open_session(codec: &mut AcpV1Codec) { + let frame = codec + .new_session_frame(PathBuf::from("/workspace"), Vec::new()) + .unwrap(); + let value: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(value["id"], "cosh-acp-2"); + assert_eq!(value["method"], "session/new"); + assert_eq!(value["params"]["cwd"], "/workspace"); + + let response = json!({ + "jsonrpc": "2.0", + "id": "cosh-acp-2", + "result": { "sessionId": SESSION_ID } + }); + assert_eq!( + codec.decode_frame(response.to_string().as_bytes()).unwrap(), + AcpV1Observation::SessionOpened { + session_id: SESSION_ID.to_owned() + } + ); +} + +fn start_prompt(codec: &mut AcpV1Codec) -> Value { + let frame = codec.prompt_frame("inspect this workspace").unwrap(); + serde_json::from_str(&frame).unwrap() +} + +#[test] +fn initialization_pins_wire_v1_independently_from_sdk_version() { + let mut codec = codec(); + let observation = initialize( + &mut codec, + json!({ + "loadSession": true, + "promptCapabilities": { + "image": true, + "embeddedContext": true + }, + "sessionCapabilities": { + "additionalDirectories": {}, + "resume": {}, + "close": {} + } + }), + ); + + assert_eq!(codec.phase(), AcpV1ProtocolPhase::Ready); + assert_eq!( + observation, + AcpV1Observation::Initialized { + agent_info: Some(AcpV1AgentInfo { + name: "fake-agent".to_owned(), + title: Some("Fake Agent".to_owned()), + version: "1.2.3".to_owned(), + }), + capabilities: AcpV1AgentCapabilities { + load_session: true, + additional_directories: true, + resume_session: true, + close_session: true, + image_prompts: true, + embedded_context: true, + ..AcpV1AgentCapabilities::default() + } + } + ); + assert_eq!(ACP_WIRE_PROTOCOL_VERSION, 1); +} + +#[test] +fn wrong_protocol_version_fails_closed() { + let mut codec = codec(); + codec.initialize_frame().unwrap(); + let response = json!({ + "jsonrpc": "2.0", + "id": "cosh-acp-1", + "result": { "protocolVersion": 2 } + }); + + assert!(matches!( + codec.decode_frame(response.to_string().as_bytes()), + Err(AcpV1CodecError::UnsupportedProtocolVersion { actual: 2 }) + )); + assert_eq!(codec.phase(), AcpV1ProtocolPhase::Terminal); +} + +#[test] +fn session_requires_absolute_and_advertised_additional_roots() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + + assert!(matches!( + codec.new_session_frame("relative", Vec::new()), + Err(AcpV1CodecError::WorkspaceNotAbsolute(_)) + )); + assert!(matches!( + codec.new_session_frame("/workspace", vec![PathBuf::from("/workspace-secondary")]), + Err(AcpV1CodecError::UnsupportedCapability( + "session.additionalDirectories" + )) + )); +} + +#[test] +fn prompt_update_and_terminal_response_preserve_session_binding() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + + let prompt = start_prompt(&mut codec); + assert_eq!(prompt["id"], "cosh-acp-3"); + assert_eq!(prompt["method"], "session/prompt"); + assert_eq!(prompt["params"]["sessionId"], SESSION_ID); + assert_eq!(prompt["params"]["prompt"][0]["type"], "text"); + assert_eq!( + prompt["params"]["prompt"][0]["text"], + "inspect this workspace" + ); + + let update = json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": SESSION_ID, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "working" }, + "messageId": "message-1" + } + } + }); + let observation = codec.decode_frame(update.to_string().as_bytes()).unwrap(); + let AcpV1Observation::SessionUpdate { session_id, update } = observation else { + panic!("expected session update"); + }; + assert_eq!(session_id, SESSION_ID); + assert_eq!(update["sessionUpdate"], "agent_message_chunk"); + assert_eq!(update["content"]["text"], "working"); + + let result = json!({ + "jsonrpc": "2.0", + "id": "cosh-acp-3", + "result": { "stopReason": "end_turn" } + }); + assert_eq!( + codec.decode_frame(result.to_string().as_bytes()).unwrap(), + AcpV1Observation::PromptFinished { + session_id: SESSION_ID.to_owned(), + stop_reason: AcpV1StopReason::EndTurn, + } + ); +} + +#[test] +fn update_for_another_session_fails_closed() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + start_prompt(&mut codec); + let update = json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "spoofed-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "spoofed" } + } + } + }); + + assert!(matches!( + codec.decode_frame(update.to_string().as_bytes()), + Err(AcpV1CodecError::SessionMismatch { .. }) + )); + assert_eq!(codec.phase(), AcpV1ProtocolPhase::Terminal); +} + +#[test] +fn update_without_an_active_prompt_fails_closed() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + let update = json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": SESSION_ID, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": "late" } + } + } + }); + + assert!(matches!( + codec.decode_frame(update.to_string().as_bytes()), + Err(AcpV1CodecError::PromptNotActive) + )); +} + +fn permission_request(id: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "session/request_permission", + "params": { + "sessionId": SESSION_ID, + "toolCall": { + "toolCallId": "tool-1", + "title": "Run diagnostics" + }, + "options": [ + { + "optionId": "allow-once", + "name": "Allow once", + "kind": "allow_once" + }, + { + "optionId": "reject-once", + "name": "Reject once", + "kind": "reject_once" + } + ] + } + }) +} + +#[test] +fn permission_response_is_bound_to_offered_option() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + start_prompt(&mut codec); + let observation = codec + .decode_frame(permission_request(json!(41)).to_string().as_bytes()) + .unwrap(); + let AcpV1Observation::PermissionRequested(request) = observation else { + panic!("expected permission request"); + }; + assert_eq!(request.request_id, AcpV1RequestId::Number(41)); + assert_eq!(request.session_id, SESSION_ID); + assert_eq!(request.options.len(), 2); + + assert!(matches!( + codec.permission_response_frame( + &request.request_id, + AcpV1PermissionDecision::Selected { + option_id: "allow-always".to_owned() + } + ), + Err(AcpV1CodecError::UnknownPermissionOption { .. }) + )); + let frame = codec + .permission_response_frame( + &request.request_id, + AcpV1PermissionDecision::Selected { + option_id: "allow-once".to_owned(), + }, + ) + .unwrap(); + let value: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(value["id"], 41); + assert_eq!(value["result"]["outcome"]["outcome"], "selected"); + assert_eq!(value["result"]["outcome"]["optionId"], "allow-once"); +} + +#[test] +fn durable_permission_options_cannot_cross_the_mvp_proxy() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + start_prompt(&mut codec); + let callback = json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "session/request_permission", + "params": { + "sessionId": SESSION_ID, + "toolCall": { "toolCallId": "tool-2", "title": "Persist trust" }, + "options": [{ + "optionId": "allow-always", + "name": "Allow always", + "kind": "allow_always" + }] + } + }); + let observation = codec.decode_frame(callback.to_string().as_bytes()).unwrap(); + let AcpV1Observation::PermissionRequested(request) = observation else { + panic!("expected permission request"); + }; + + assert!(matches!( + codec.permission_response_frame( + &request.request_id, + AcpV1PermissionDecision::Selected { + option_id: "allow-always".to_owned(), + }, + ), + Err(AcpV1CodecError::UnsupportedPermissionOption { .. }) + )); +} + +#[test] +fn cancel_settles_every_pending_permission() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + start_prompt(&mut codec); + codec + .decode_frame(permission_request(json!(41)).to_string().as_bytes()) + .unwrap(); + codec + .decode_frame( + permission_request(json!("agent-request-2")) + .to_string() + .as_bytes(), + ) + .unwrap(); + + let frames = codec.cancel_frames().unwrap(); + assert_eq!(frames.len(), 3); + let cancel: Value = serde_json::from_str(&frames[0]).unwrap(); + assert_eq!(cancel["method"], "session/cancel"); + assert_eq!(cancel["params"]["sessionId"], SESSION_ID); + for frame in &frames[1..] { + let response: Value = serde_json::from_str(frame).unwrap(); + assert_eq!(response["result"]["outcome"]["outcome"], "cancelled"); + } + assert!(matches!( + codec.cancel_frames(), + Err(AcpV1CodecError::CancellationAlreadySent) + )); +} + +#[test] +fn unadvertised_callback_gets_correlated_method_not_found() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + start_prompt(&mut codec); + let callback = json!({ + "jsonrpc": "2.0", + "id": "agent-fs-1", + "method": "fs/read_text_file", + "params": { "sessionId": SESSION_ID, "path": "/etc/passwd" } + }); + let observation = codec.decode_frame(callback.to_string().as_bytes()).unwrap(); + let AcpV1Observation::UnsupportedClientRequest { request_id, method } = observation else { + panic!("expected unsupported client request"); + }; + assert_eq!(method, "fs/read_text_file"); + let frame = codec.reject_unsupported_request_frame(&request_id).unwrap(); + let response: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(response["id"], "agent-fs-1"); + assert_eq!(response["error"]["code"], -32601); +} + +#[test] +fn malformed_or_oversized_frames_make_codec_terminal() { + let mut malformed = codec(); + malformed.initialize_frame().unwrap(); + assert!(matches!( + malformed.decode_frame(b"not-json"), + Err(AcpV1CodecError::Json(_)) + )); + assert_eq!(malformed.phase(), AcpV1ProtocolPhase::Terminal); + + let mut oversized = AcpV1Codec::new(AcpV1ClientConfig::new("cosh", "1", 512)).unwrap(); + oversized.initialize_frame().unwrap(); + let frame = vec![b'x'; 513]; + assert!(matches!( + oversized.decode_frame(&frame), + Err(AcpV1CodecError::FrameTooLarge { limit: 512 }) + )); + assert_eq!(oversized.phase(), AcpV1ProtocolPhase::Terminal); +} + +#[test] +fn client_config_enforces_frame_safety_ceiling() { + assert!(matches!( + AcpV1Codec::new(AcpV1ClientConfig::new("cosh", "1", 0)), + Err(AcpV1CodecError::InvalidFrameLimit { actual: 0, .. }) + )); + assert!(matches!( + AcpV1Codec::new(AcpV1ClientConfig::new("cosh", "1", 1024 * 1024 + 1)), + Err(AcpV1CodecError::InvalidFrameLimit { .. }) + )); +} + +#[test] +fn prompt_cannot_finish_with_pending_permission() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + start_prompt(&mut codec); + codec + .decode_frame(permission_request(json!(41)).to_string().as_bytes()) + .unwrap(); + let result = json!({ + "jsonrpc": "2.0", + "id": "cosh-acp-3", + "result": { "stopReason": "end_turn" } + }); + + assert!(matches!( + codec.decode_frame(result.to_string().as_bytes()), + Err(AcpV1CodecError::PromptFinishedWithPendingPermissions { count: 1 }) + )); + assert_eq!(codec.phase(), AcpV1ProtocolPhase::Terminal); +} + +#[test] +fn pending_agent_callback_count_is_bounded() { + let mut codec = codec(); + initialize(&mut codec, json!({})); + open_session(&mut codec); + for request_id in 0..64 { + let callback = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "fs/read_text_file", + "params": { "sessionId": SESSION_ID, "path": "/tmp/input" } + }); + assert!(matches!( + codec.decode_frame(callback.to_string().as_bytes()), + Ok(AcpV1Observation::UnsupportedClientRequest { .. }) + )); + } + let overflow = json!({ + "jsonrpc": "2.0", + "id": 64, + "method": "fs/read_text_file", + "params": { "sessionId": SESSION_ID, "path": "/tmp/input" } + }); + assert!(matches!( + codec.decode_frame(overflow.to_string().as_bytes()), + Err(AcpV1CodecError::TooManyPendingClientRequests { limit: 64 }) + )); + assert_eq!(codec.phase(), AcpV1ProtocolPhase::Terminal); +} + +#[cfg(unix)] +#[test] +fn bridge_runs_v1_exchange_over_supervised_stdio() { + let workspace = tempfile::tempdir().unwrap(); + let log_path = workspace.path().join("requests.jsonl"); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + printf '%s\n' "$line" >> "$1" + case "$step" in + 1) + printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{},"agentInfo":{"name":"stdio-fake","version":"1.0"}}}' + ;; + 2) + printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"agent-session-1"}}' + ;; + 3) + printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"agent-session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}' + printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-3","result":{"stopReason":"end_turn"}}' + ;; + esac +done +"#; + let mut spec = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + spec.arguments = vec![ + "-c".into(), + script.into(), + "acp-fake".into(), + log_path.clone().into_os_string(), + ]; + let mut bridge = AcpV1RuntimeBridge::launch( + &spec, + AcpV1ClientConfig::new("cosh-ng", "0.15.0", FRAME_LIMIT), + ) + .unwrap(); + + bridge.send_initialize().unwrap(); + assert!(matches!( + bridge.read_observation().unwrap(), + Some(AcpV1Observation::Initialized { .. }) + )); + assert_eq!(bridge.runtime_state(), RuntimeState::Ready); + + bridge + .send_new_session(workspace.path(), Vec::new()) + .unwrap(); + assert!(matches!( + bridge.read_observation().unwrap(), + Some(AcpV1Observation::SessionOpened { .. }) + )); + bridge.send_prompt("hello over ACP").unwrap(); + assert!(matches!( + bridge.read_observation().unwrap(), + Some(AcpV1Observation::SessionUpdate { .. }) + )); + assert_eq!( + bridge.read_observation().unwrap(), + Some(AcpV1Observation::PromptFinished { + session_id: SESSION_ID.to_owned(), + stop_reason: AcpV1StopReason::EndTurn, + }) + ); + bridge.shutdown(Duration::from_secs(1)).unwrap(); + + let requests = std::fs::read_to_string(log_path).unwrap(); + let requests = requests + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["method"], "initialize"); + assert_eq!(requests[0]["params"]["protocolVersion"], 1); + assert_eq!(requests[1]["method"], "session/new"); + assert_eq!(requests[2]["method"], "session/prompt"); +} + +#[cfg(unix)] +#[test] +fn bridge_reaps_agent_that_closes_stdout_without_exiting() { + let workspace = tempfile::tempdir().unwrap(); + let script = "exec 1>&-; sleep 60"; + let mut spec = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + spec.arguments = vec!["-c".into(), script.into()]; + let mut bridge = AcpV1RuntimeBridge::launch( + &spec, + AcpV1ClientConfig::new("cosh-ng", "0.15.0", FRAME_LIMIT), + ) + .unwrap(); + + bridge.send_initialize().unwrap(); + assert_eq!( + bridge.read_observation().unwrap(), + Some(AcpV1Observation::TransportClosed) + ); + assert_eq!(bridge.protocol_phase(), AcpV1ProtocolPhase::Terminal); + assert_eq!(bridge.runtime_state(), RuntimeState::Exited); +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/types.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/types.rs new file mode 100644 index 0000000000..ebab9f406c --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/acp/types.rs @@ -0,0 +1,378 @@ +//! Runtime-local ACP v1 projections that keep SDK types out of domain contracts. + +use serde_json::Value; +use thiserror::Error; + +/// Stable ACP wire version negotiated by the first COSH bridge profile. +pub const ACP_WIRE_PROTOCOL_VERSION: u16 = 1; + +/// Configuration for one ACP v1 codec instance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpV1ClientConfig { + /// Programmatic client implementation name advertised to the Agent. + pub name: String, + /// Client implementation version, independent from the ACP wire version. + pub version: String, + /// Maximum accepted or emitted JSON-RPC frame size. + pub max_frame_bytes: usize, +} + +impl AcpV1ClientConfig { + /// Builds a client configuration with an explicit frame bound. + #[must_use] + pub fn new( + name: impl Into, + version: impl Into, + max_frame_bytes: usize, + ) -> Self { + Self { + name: name.into(), + version: version.into(), + max_frame_bytes, + } + } +} + +/// Negotiation and terminal state for one ACP process generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpV1ProtocolPhase { + /// No ACP frame has been sent. + Created, + /// The initialize response is outstanding. + AwaitingInitialize, + /// ACP v1 negotiation succeeded. + Ready, + /// The wire became unusable and no more traffic is accepted. + Terminal, +} + +/// JSON-RPC request identity scoped to one ACP connection. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AcpV1RequestId { + /// Integer request identifier. + Number(i64), + /// String request identifier. + String(String), +} + +impl std::fmt::Display for AcpV1RequestId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Number(value) => write!(formatter, "{value}"), + Self::String(value) => formatter.write_str(value), + } + } +} + +/// Outbound request operation used to classify correlated responses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpV1RequestKind { + /// Connection initialization. + Initialize, + /// New Agent session creation. + NewSession, + /// One prompt turn. + Prompt, +} + +/// Agent implementation metadata copied out of the ACP SDK type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpV1AgentInfo { + /// Programmatic implementation name. + pub name: String, + /// Optional human-readable title. + pub title: Option, + /// Agent implementation version. + pub version: String, +} + +/// Immutable subset of stable ACP v1 capabilities needed by later bridge phases. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct AcpV1AgentCapabilities { + /// Agent supports `session/load`. + pub load_session: bool, + /// Agent supports `session/list`. + pub list_sessions: bool, + /// Agent supports `session/delete`. + pub delete_session: bool, + /// Agent accepts additional workspace roots. + pub additional_directories: bool, + /// Agent supports `session/resume`. + pub resume_session: bool, + /// Agent supports `session/close`. + pub close_session: bool, + /// Agent accepts image prompt blocks. + pub image_prompts: bool, + /// Agent accepts audio prompt blocks. + pub audio_prompts: bool, + /// Agent accepts embedded resource prompt blocks. + pub embedded_context: bool, +} + +/// Normalized ACP prompt stop reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpV1StopReason { + /// Agent completed the turn normally. + EndTurn, + /// Agent reached its token limit. + MaxTokens, + /// Agent reached its request limit for the turn. + MaxTurnRequests, + /// Agent refused the prompt. + Refusal, + /// Agent acknowledged client cancellation. + Cancelled, + /// SDK added a stable value that this bridge version does not yet map. + Unsupported, +} + +/// Display classification for an Agent-provided permission option. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpV1PermissionOptionKind { + /// Permit only the current operation. + AllowOnce, + /// Request a durable allow choice; COSH policy may still narrow it. + AllowAlways, + /// Reject only the current operation. + RejectOnce, + /// Request a durable rejection choice. + RejectAlways, + /// SDK added an option kind that this bridge version does not yet map. + Unsupported, +} + +/// One untrusted option supplied by an ACP Agent for user presentation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcpV1PermissionOption { + /// Opaque Agent option identity. + pub option_id: String, + /// Untrusted human-readable option label. + pub name: String, + /// Presentation hint; this is never an authorization decision by itself. + pub kind: AcpV1PermissionOptionKind, +} + +/// Validated permission callback awaiting the COSH governance path. +#[derive(Debug, Clone, PartialEq)] +pub struct AcpV1PermissionRequest { + /// Agent-owned JSON-RPC correlation identifier. + pub request_id: AcpV1RequestId, + /// Opaque ACP session identity bound by this codec. + pub session_id: String, + /// Validated ACP tool call payload retained for later policy normalization. + pub tool_call: Value, + /// Untrusted Agent-provided choices. + pub options: Vec, +} + +/// Decision sent back after the COSH governance path resolves a permission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AcpV1PermissionDecision { + /// The prompt or permission interaction was cancelled. + Cancelled, + /// Select one option that appeared in the correlated Agent request. + Selected { + /// Opaque Agent option identity. + option_id: String, + }, +} + +/// One validated observation from an ACP v1 Agent. +#[derive(Debug, Clone, PartialEq)] +pub enum AcpV1Observation { + /// Exact wire-version negotiation succeeded. + Initialized { + /// Optional Agent implementation metadata. + agent_info: Option, + /// Immutable stable capability snapshot. + capabilities: AcpV1AgentCapabilities, + }, + /// A new opaque ACP session was created. + SessionOpened { + /// Agent-owned session identifier; it is not a COSH Task or Run ID. + session_id: String, + }, + /// A stable `session/update` payload validated by official ACP v1 types. + SessionUpdate { + /// Bound Agent session identifier. + session_id: String, + /// Validated update serialized into a runtime-local neutral value. + update: Value, + }, + /// Agent requests a permission decision during a prompt. + PermissionRequested(AcpV1PermissionRequest), + /// An Agent request outside the narrow first client profile was rejected. + /// + /// The session actor has already sent method-not-found before publishing + /// this diagnostic observation; consumers must not answer it again. + UnsupportedClientRequest { + /// Request identifier that received the fail-closed response. + request_id: AcpV1RequestId, + /// Unrecognized or unadvertised method name. + method: String, + }, + /// An extension or unsupported notification was ignored diagnostically. + UnsupportedNotification { + /// Unrecognized notification method. + method: String, + }, + /// One prompt request reached its ACP terminal response. + PromptFinished { + /// Bound Agent session identifier. + session_id: String, + /// Normalized stable stop reason. + stop_reason: AcpV1StopReason, + }, + /// Agent returned a JSON-RPC error for a correlated COSH request. + RequestFailed { + /// Operation that failed. + request: AcpV1RequestKind, + /// Numeric JSON-RPC or ACP error code. + code: i32, + /// Agent-provided diagnostic message. + message: String, + }, + /// Runtime stdout closed before the bridge was explicitly shut down. + TransportClosed, +} + +/// ACP v1 codec validation or state failure. +#[derive(Debug, Error)] +pub enum AcpV1CodecError { + /// Frame limit must fit the bridge safety envelope. + #[error("invalid ACP frame limit {actual}; expected 1..={maximum}")] + InvalidFrameLimit { + /// Rejected frame limit. + actual: usize, + /// Hard safety ceiling. + maximum: usize, + }, + /// Client implementation metadata must be non-empty. + #[error("ACP client {field} must not be empty")] + InvalidClientInfo { + /// Invalid metadata field. + field: &'static str, + }, + /// Operation is invalid in the current protocol phase. + #[error("ACP operation {operation} is invalid while phase is {phase:?}")] + InvalidPhase { + /// Requested codec operation. + operation: &'static str, + /// Current protocol phase. + phase: AcpV1ProtocolPhase, + }, + /// Frame was empty after newline removal. + #[error("ACP frame must not be empty")] + EmptyFrame, + /// Frame exceeded the configured hard bound. + #[error("ACP frame exceeds {limit} bytes")] + FrameTooLarge { + /// Configured maximum frame bytes. + limit: usize, + }, + /// Frame was not valid UTF-8. + #[error("ACP frame is not valid UTF-8")] + InvalidUtf8, + /// Official SDK JSON parsing or serialization failed. + #[error("invalid ACP JSON-RPC frame: {0}")] + Json(#[from] serde_json::Error), + /// Official SDK rejected construction of a typed JSON-RPC message. + #[error("ACP SDK rejected JSON-RPC message: {0}")] + Sdk(String), + /// Agent selected a wire version the bridge does not implement. + #[error("ACP Agent selected unsupported protocol version {actual}; expected 1")] + UnsupportedProtocolVersion { + /// Agent-selected numeric protocol version. + actual: u16, + }, + /// A response did not match any outstanding client request. + #[error("ACP response references unknown request id {0}")] + UnknownResponse(AcpV1RequestId), + /// JSON-RPC null cannot safely correlate bidirectional callbacks. + #[error("ACP request id must not be null")] + NullRequestId, + /// Workspace roots must be absolute before reaching the Agent. + #[error("ACP workspace path must be absolute: {0}")] + WorkspaceNotAbsolute(std::path::PathBuf), + /// Only one Agent session is supported by this first codec profile. + #[error("ACP session is already bound to this codec")] + SessionAlreadyBound, + /// An operation needs a successfully opened Agent session. + #[error("ACP operation requires an open session")] + SessionNotOpen, + /// Agent referenced a session other than the bound session. + #[error("ACP session mismatch: expected {expected:?}, received {actual:?}")] + SessionMismatch { + /// Bound opaque session identity. + expected: String, + /// Received opaque session identity. + actual: String, + }, + /// A prompt is already active. + #[error("ACP prompt is already active")] + PromptAlreadyActive, + /// Cancellation or permission callbacks require an active prompt. + #[error("ACP prompt is not active")] + PromptNotActive, + /// Prompt text must not be empty. + #[error("ACP prompt text must not be empty")] + EmptyPrompt, + /// Optional method or field was used without Agent advertisement. + #[error("ACP Agent did not advertise capability {0}")] + UnsupportedCapability(&'static str), + /// A second cancellation was attempted before the prompt settled. + #[error("ACP cancellation was already sent for the active prompt")] + CancellationAlreadySent, + /// Agent reused an outstanding callback identity. + #[error("ACP Agent reused pending request id {0}")] + DuplicateInboundRequest(AcpV1RequestId), + /// Agent exceeded the bounded callback queue. + #[error("ACP Agent has too many pending client requests; maximum is {limit}")] + TooManyPendingClientRequests { + /// Hard limit for one connection. + limit: usize, + }, + /// Permission callback had no selectable options. + #[error("ACP permission request must provide at least one option")] + EmptyPermissionOptions, + /// Permission callback reused an option identity. + #[error("ACP permission request contains duplicate option id {0:?}")] + DuplicatePermissionOption(String), + /// Permission response did not correlate to a pending callback. + #[error("ACP permission request {0} is not pending")] + UnknownPermissionRequest(AcpV1RequestId), + /// Selected permission option did not appear in the correlated request. + #[error("ACP permission option {option_id:?} was not offered for request {request_id}")] + UnknownPermissionOption { + /// Correlated Agent request. + request_id: AcpV1RequestId, + /// Rejected option identity. + option_id: String, + }, + /// The Agent offered an option outside the MVP once-only boundary. + #[error("ACP permission option {option_id:?} for request {request_id} is not once-only")] + UnsupportedPermissionOption { + /// Correlated Agent request. + request_id: AcpV1RequestId, + /// Rejected option identity. + option_id: String, + }, + /// Unsupported callback rejection did not correlate to an observed request. + #[error("ACP unsupported request {0} is not pending")] + UnknownUnsupportedRequest(AcpV1RequestId), + /// Outbound request sequence exceeded the supported JSON-RPC range. + #[error("ACP request id sequence exhausted")] + RequestIdExhausted, + /// Prompt settled while callbacks still required a response. + #[error("ACP prompt finished with {count} pending permission requests")] + PromptFinishedWithPendingPermissions { + /// Number of unsettled permission callbacks. + count: usize, + }, + /// Prompt settled while unsupported callbacks still required rejection. + #[error("ACP prompt finished with {count} pending unsupported requests")] + PromptFinishedWithPendingUnsupported { + /// Number of callbacks still awaiting method-not-found. + count: usize, + }, +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/bounded_io.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/bounded_io.rs new file mode 100644 index 0000000000..6d837408bf --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/bounded_io.rs @@ -0,0 +1,444 @@ +//! Bounded readers for runtime stdout framing and diagnostic stderr tails. + +use std::collections::VecDeque; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +/// Failure while decoding a bounded newline-delimited frame. +#[derive(Debug, Error)] +pub enum BoundedLineError { + /// Reading the underlying pipe failed. + #[error("failed to read runtime stdout: {0}")] + Io(#[from] io::Error), + /// A peer exceeded the configured wire-frame limit. + #[error("runtime stdout line exceeds the {limit}-byte limit")] + TooLarge { + /// Maximum accepted frame size, excluding the line delimiter. + limit: usize, + }, + /// The wire frame was not valid UTF-8. + #[error("runtime stdout line is not valid UTF-8")] + InvalidUtf8, +} + +/// Newline-delimited reader that never allocates beyond one bounded frame. +#[derive(Debug)] +pub struct BoundedLineReader { + reader: BufReader, + max_line_bytes: usize, +} + +/// Result of waiting for one line from an asynchronous bounded reader. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BoundedLineRead { + /// One complete line was read. + Line(String), + /// The underlying stream reached EOF. + Eof, + /// No line became available within the caller's deadline. + TimedOut, +} + +/// Single-reader background adapter that keeps protocol control responsive. +#[derive(Debug)] +pub(crate) struct BoundedLineChannel { + receiver: Option, BoundedLineError>>>, + reader: Option>, +} + +#[derive(Debug)] +struct WriteRequest { + frame: Vec, + reply: SyncSender>, +} + +/// Single-writer background adapter that bounds pipe-write latency for owners. +#[derive(Debug)] +pub(crate) struct BoundedWriteChannel { + sender: Option>, + writer: Option>, +} + +impl BoundedWriteChannel { + pub(crate) fn spawn(mut writer: W) -> io::Result + where + W: Write + Send + 'static, + { + let (sender, receiver) = mpsc::sync_channel::(1); + let writer = thread::Builder::new() + .name("cosh-runtime-stdin".to_owned()) + .spawn(move || { + while let Ok(request) = receiver.recv() { + let result = writer + .write_all(&request.frame) + .and_then(|()| writer.flush()); + let failed = result.is_err(); + let _ = request.reply.send(result); + if failed { + break; + } + } + })?; + Ok(Self { + sender: Some(sender), + writer: Some(writer), + }) + } + + pub(crate) fn write_timeout(&self, frame: Vec, timeout: Duration) -> io::Result<()> { + let sender = self.sender.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stdin writer unavailable", + ) + })?; + let (reply, result) = mpsc::sync_channel(1); + let deadline = std::time::Instant::now() + timeout; + let mut request = WriteRequest { frame, reply }; + loop { + match sender.try_send(request) { + Ok(()) => break, + Err(TrySendError::Full(returned)) => { + request = returned; + if std::time::Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "runtime stdin queue deadline exceeded", + )); + } + thread::sleep(Duration::from_millis(1)); + } + Err(TrySendError::Disconnected(_)) => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stdin writer stopped", + )); + } + } + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + result + .recv_timeout(remaining) + .map_err(|error| match error { + RecvTimeoutError::Timeout => io::Error::new( + io::ErrorKind::TimedOut, + "runtime stdin write deadline exceeded", + ), + RecvTimeoutError::Disconnected => { + io::Error::new(io::ErrorKind::BrokenPipe, "runtime stdin writer stopped") + } + })? + } + + pub(crate) fn finish(mut self) { + self.sender.take(); + if self.writer.as_ref().is_some_and(JoinHandle::is_finished) { + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + } + } +} + +impl BoundedLineChannel { + pub(crate) fn spawn(reader: R, max_line_bytes: usize) -> io::Result + where + R: Read + Send + 'static, + { + // A single queued frame applies backpressure without allowing a silent + // or chatty Agent to monopolize the supervisor owner thread. + let (sender, receiver) = mpsc::sync_channel(1); + let reader = thread::Builder::new() + .name("cosh-runtime-stdout".to_string()) + .spawn(move || { + let mut reader = BoundedLineReader::new(reader, max_line_bytes); + loop { + let result = reader.read_line(); + let terminal = !matches!(result, Ok(Some(_))); + if sender.send(result).is_err() || terminal { + break; + } + } + })?; + Ok(Self { + receiver: Some(receiver), + reader: Some(reader), + }) + } + + pub(crate) fn read_timeout( + &self, + timeout: Duration, + ) -> Result { + let receiver = self.receiver.as_ref().ok_or_else(|| { + BoundedLineError::Io(io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stdout receiver unavailable", + )) + })?; + match receiver.recv_timeout(timeout) { + Ok(Ok(Some(line))) => Ok(BoundedLineRead::Line(line)), + Ok(Ok(None)) => Ok(BoundedLineRead::Eof), + Ok(Err(error)) => Err(error), + Err(RecvTimeoutError::Timeout) => Ok(BoundedLineRead::TimedOut), + Err(RecvTimeoutError::Disconnected) => Err(BoundedLineError::Io(io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stdout reader stopped without EOF", + ))), + } + } + + pub(crate) fn finish(mut self) { + // Drop the receiver first so a reader blocked on the bounded sender can + // exit even when the child emitted an unread frame during shutdown. + self.receiver.take(); + if self.reader.as_ref().is_some_and(JoinHandle::is_finished) { + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } + } +} + +impl BoundedLineReader { + /// Wraps a reader with a non-zero per-line byte limit. + /// + /// # Panics + /// + /// Panics when `max_line_bytes` is zero. Launch validation prevents that + /// configuration for supervised runtimes. + pub fn new(reader: R, max_line_bytes: usize) -> Self { + assert!(max_line_bytes > 0, "bounded line limit must be non-zero"); + Self { + reader: BufReader::new(reader), + max_line_bytes, + } + } + + /// Reads one UTF-8 line without its trailing CR/LF delimiter. + /// + /// # Errors + /// + /// Returns an error for I/O failure, invalid UTF-8, or a frame larger + /// than the configured bound. + pub fn read_line(&mut self) -> Result, BoundedLineError> { + let mut frame = Vec::with_capacity(self.max_line_bytes.min(8 * 1024)); + let mut limited = self.reader.by_ref().take(self.max_line_bytes as u64 + 2); + let bytes_read = limited.read_until(b'\n', &mut frame)?; + if bytes_read == 0 { + return Ok(None); + } + + let has_newline = frame.last() == Some(&b'\n'); + let without_newline = frame.len() - usize::from(has_newline); + let has_carriage_return = + has_newline && without_newline > 0 && frame.get(without_newline - 1) == Some(&b'\r'); + let payload_len = without_newline - usize::from(has_carriage_return); + if payload_len > self.max_line_bytes || (!has_newline && frame.len() > self.max_line_bytes) + { + return Err(BoundedLineError::TooLarge { + limit: self.max_line_bytes, + }); + } + + frame.truncate(payload_len); + String::from_utf8(frame) + .map(Some) + .map_err(|_| BoundedLineError::InvalidUtf8) + } +} + +#[derive(Debug)] +struct StderrTail { + bytes: VecDeque, + capacity: usize, + discarded_bytes: u64, + read_error: Option, +} + +impl StderrTail { + fn new(capacity: usize) -> Self { + Self { + bytes: VecDeque::with_capacity(capacity), + capacity, + discarded_bytes: 0, + read_error: None, + } + } + + fn push(&mut self, chunk: &[u8]) { + let overflow = self + .bytes + .len() + .saturating_add(chunk.len()) + .saturating_sub(self.capacity); + for _ in 0..overflow.min(self.bytes.len()) { + self.bytes.pop_front(); + } + + if chunk.len() >= self.capacity { + self.bytes.clear(); + let start = chunk.len() - self.capacity; + self.bytes.extend(&chunk[start..]); + } else { + self.bytes.extend(chunk); + } + self.discarded_bytes = self.discarded_bytes.saturating_add(overflow as u64); + } + + fn snapshot(&self) -> StderrSnapshot { + let bytes = self.bytes.iter().copied().collect::>(); + StderrSnapshot { + tail: String::from_utf8_lossy(&bytes).into_owned(), + discarded_bytes: self.discarded_bytes, + read_error: self.read_error.clone(), + } + } +} + +/// Bounded diagnostic output retained after a runtime exits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StderrSnapshot { + /// Lossy UTF-8 view of the most recent stderr bytes. + pub tail: String, + /// Number of older bytes discarded to preserve the bound. + pub discarded_bytes: u64, + /// Reader failure, when stderr could not be drained to EOF. + pub read_error: Option, +} + +#[derive(Debug)] +pub(crate) struct StderrCollector { + tail: Arc>, + reader: Option>, +} + +impl StderrCollector { + pub(crate) fn spawn(mut stderr: R, capacity: usize) -> io::Result + where + R: Read + Send + 'static, + { + let tail = Arc::new(Mutex::new(StderrTail::new(capacity))); + let reader_tail = Arc::clone(&tail); + let reader = thread::Builder::new() + .name("cosh-runtime-stderr".to_string()) + .spawn(move || { + let mut chunk = [0_u8; 8 * 1024]; + loop { + match stderr.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + let Ok(mut tail) = reader_tail.lock() else { + break; + }; + tail.push(&chunk[..read]); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => { + if let Ok(mut tail) = reader_tail.lock() { + tail.read_error = Some(error.to_string()); + } + break; + } + } + } + })?; + Ok(Self { + tail, + reader: Some(reader), + }) + } + + pub(crate) fn finish(mut self) -> StderrSnapshot { + // The child has already been reaped before settlement, so its pipe + // should close promptly. A short bound preserves final diagnostics + // without allowing a leaked descendant fd to block shutdown. + let deadline = Instant::now() + Duration::from_millis(100); + while self + .reader + .as_ref() + .is_some_and(|reader| !reader.is_finished()) + && Instant::now() < deadline + { + thread::sleep(Duration::from_millis(1)); + } + if self.reader.as_ref().is_some_and(JoinHandle::is_finished) { + if self + .reader + .take() + .is_some_and(|reader| reader.join().is_err()) + { + if let Ok(mut tail) = self.tail.lock() { + tail.read_error = Some("stderr reader thread panicked".to_string()); + } + } + } else if let Ok(mut tail) = self.tail.lock() { + tail.read_error = Some("stderr reader still active at settlement".to_string()); + } + self.snapshot() + } + + pub(crate) fn snapshot(&self) -> StderrSnapshot { + self.tail + .lock() + .map(|tail| tail.snapshot()) + .unwrap_or_else(|_| StderrSnapshot { + tail: String::new(), + discarded_bytes: 0, + read_error: Some("stderr tail lock poisoned".to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn bounded_line_reader_accepts_crlf_and_eof_frame() { + let input = Cursor::new(b"first\r\nsecond".to_vec()); + let mut reader = BoundedLineReader::new(input, 16); + + assert_eq!(reader.read_line().unwrap().as_deref(), Some("first")); + assert_eq!(reader.read_line().unwrap().as_deref(), Some("second")); + assert_eq!(reader.read_line().unwrap(), None); + } + + #[test] + fn bounded_line_reader_handles_empty_lf_and_crlf_frames() { + let input = Cursor::new(b"\n\r\n".to_vec()); + let mut reader = BoundedLineReader::new(input, 8); + + assert_eq!(reader.read_line().unwrap().as_deref(), Some("")); + assert_eq!(reader.read_line().unwrap().as_deref(), Some("")); + assert_eq!(reader.read_line().unwrap(), None); + } + + #[test] + fn bounded_line_reader_rejects_oversized_frame_without_unbounded_allocation() { + let input = Cursor::new(b"123456789\n".to_vec()); + let mut reader = BoundedLineReader::new(input, 8); + + assert!(matches!( + reader.read_line(), + Err(BoundedLineError::TooLarge { limit: 8 }) + )); + } + + #[test] + fn stderr_tail_retains_only_latest_bytes() { + let mut tail = StderrTail::new(5); + tail.push(b"abc"); + tail.push(b"defg"); + + assert_eq!(tail.snapshot().tail, "cdefg"); + assert_eq!(tail.snapshot().discarded_bytes, 2); + } +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs new file mode 100644 index 0000000000..1c2c3a64ce --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs @@ -0,0 +1,13 @@ +//! Pure codec for the private cosh-core newline-delimited JSON protocol. +//! +//! `PRIVATE_COSH_CONTROL_PROTOCOL_VERSION` versions this internal COSH wire +//! contract. It is unrelated to ACP and must never be advertised as ACP. + +mod codec; +mod types; + +#[cfg(test)] +mod tests; + +pub use codec::CoshCoreJsonlCodec; +pub use types::*; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/codec.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/codec.rs new file mode 100644 index 0000000000..e572fa63f3 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/codec.rs @@ -0,0 +1,467 @@ +//! Stateful encoder and decoder for private cosh-core JSONL frames. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::types::*; + +/// Stateful encoder/decoder for one private cosh-core process lifecycle. +#[derive(Debug)] +pub struct CoshCoreJsonlCodec { + initialize_request_id: String, + max_frame_bytes: usize, + phase: CoshCoreProtocolPhase, +} + +impl CoshCoreJsonlCodec { + /// Creates a codec for one correlated initialization exchange. + /// + /// # Errors + /// + /// Returns `InvalidLimit` when `max_frame_bytes` is zero. + pub fn new( + initialize_request_id: impl Into, + max_frame_bytes: usize, + ) -> Result { + if max_frame_bytes == 0 { + return Err(CoshCoreCodecError::InvalidLimit); + } + Ok(Self { + initialize_request_id: initialize_request_id.into(), + max_frame_bytes, + phase: CoshCoreProtocolPhase::Created, + }) + } + + /// Returns the current private protocol phase. + pub fn phase(&self) -> CoshCoreProtocolPhase { + self.phase + } + + /// Encodes the mandatory exact-version initialize request. + /// + /// # Errors + /// + /// Returns an invalid-phase or frame-bound error. + pub fn initialize_frame( + &mut self, + fire_session_start: bool, + ) -> Result { + if self.phase != CoshCoreProtocolPhase::Created { + return Err(self.invalid_phase("initialize_frame")); + } + let frame = encode_frame( + &InitializeInput { + message_type: "control_request", + request_id: &self.initialize_request_id, + request: InitializeRequest { + subtype: "initialize", + fire_session_start, + protocol_version: PRIVATE_COSH_CONTROL_PROTOCOL_VERSION, + }, + }, + self.max_frame_bytes, + )?; + self.phase = CoshCoreProtocolPhase::AwaitingInitialize; + Ok(frame) + } + + /// Encodes one typed user turn after successful negotiation. + /// + /// # Errors + /// + /// Returns an invalid-phase, serialization, or frame-bound error. + pub fn user_frame(&self, turn: &CoshCoreUserTurn) -> Result { + if self.phase != CoshCoreProtocolPhase::Ready { + return Err(self.invalid_phase("user_frame")); + } + encode_frame( + &UserInput { + message_type: "user", + message: UserInputBody { + role: "user", + content: &turn.content, + raw_user_input: turn.raw_user_input.as_deref(), + }, + session_id: turn.provider_session_id.as_deref(), + shell_context: turn.shell_context.as_ref(), + }, + self.max_frame_bytes, + ) + } + + /// Encodes a correlated interrupt request during initialization or a turn. + /// + /// # Errors + /// + /// Returns an invalid-phase, serialization, or frame-bound error. + pub fn interrupt_frame(&self, request_id: &str) -> Result { + self.control_frame(request_id, "interrupt", "interrupt_frame") + } + + /// Encodes a correlated graceful shutdown request. + /// + /// # Errors + /// + /// Returns an invalid-phase, serialization, or frame-bound error. + pub fn shutdown_frame(&self, request_id: &str) -> Result { + self.control_frame(request_id, "shutdown", "shutdown_frame") + } + + fn control_frame( + &self, + request_id: &str, + subtype: &'static str, + operation: &'static str, + ) -> Result { + if !matches!( + self.phase, + CoshCoreProtocolPhase::AwaitingInitialize | CoshCoreProtocolPhase::Ready + ) { + return Err(self.invalid_phase(operation)); + } + encode_frame( + &SimpleControlInput { + message_type: "control_request", + request_id, + request: SimpleControlRequest { subtype }, + }, + self.max_frame_bytes, + ) + } + + /// Decodes and validates one private cosh-core output frame. + /// + /// # Errors + /// + /// Rejects malformed/oversized frames, negotiation violations, unknown + /// message types, and any output after the first terminal result. + pub fn decode_frame( + &mut self, + frame: &[u8], + ) -> Result { + if self.phase == CoshCoreProtocolPhase::Terminal { + return Err(CoshCoreCodecError::OutputAfterTerminal); + } + if frame.len() > self.max_frame_bytes { + return Err(CoshCoreCodecError::FrameTooLarge { + limit: self.max_frame_bytes, + }); + } + let frame = std::str::from_utf8(frame).map_err(|_| CoshCoreCodecError::InvalidUtf8)?; + let frame = frame.trim_end_matches(['\r', '\n']); + if frame.is_empty() { + return Err(CoshCoreCodecError::EmptyFrame); + } + + let value: Value = serde_json::from_str(frame)?; + let message_type = value + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| CoshCoreCodecError::UnknownMessageType(String::new()))? + .to_string(); + + if self.phase == CoshCoreProtocolPhase::Created { + return Err(self.invalid_phase("decode_frame")); + } + if self.phase == CoshCoreProtocolPhase::AwaitingInitialize { + return self.decode_initializing(&message_type, value); + } + self.decode_ready(&message_type, value) + } + + /// Produces one synthetic terminal when stdout ends before a core result. + pub fn finish_stdout(&mut self) -> Option { + if self.phase == CoshCoreProtocolPhase::Terminal { + return None; + } + self.phase = CoshCoreProtocolPhase::Terminal; + Some(CoshCoreObservation::ProtocolEndedWithoutResult) + } + + fn decode_initializing( + &mut self, + message_type: &str, + value: Value, + ) -> Result { + match message_type { + "control_response" => self.decode_initialize_response(value), + "control_request" => { + let request = decode_control_request(value)?; + if matches!(request.request, CoshCoreControlRequest::AuthRequired { .. }) { + Ok(CoshCoreObservation::ControlRequest(request)) + } else { + Err(CoshCoreCodecError::UnexpectedBeforeInitialization( + "control_request".to_string(), + )) + } + } + other => Err(CoshCoreCodecError::UnexpectedBeforeInitialization( + other.to_string(), + )), + } + } + + fn decode_initialize_response( + &mut self, + value: Value, + ) -> Result { + let envelope: WireControlResponseEnvelope = serde_json::from_value(value)?; + if envelope.response.request_id != self.initialize_request_id + || envelope.response.response.subtype != "initialize" + { + return Err(CoshCoreCodecError::InitializeCorrelationMismatch); + } + if envelope.response.subtype != "success" { + return Err(CoshCoreCodecError::InitializeRejected( + envelope + .response + .response + .error + .unwrap_or_else(|| envelope.response.subtype.clone()), + )); + } + let version = envelope + .response + .response + .protocol_version + .ok_or(CoshCoreCodecError::InitializeVersionMissing)?; + if version != PRIVATE_COSH_CONTROL_PROTOCOL_VERSION { + return Err(CoshCoreCodecError::InitializeVersionMismatch { + required: PRIVATE_COSH_CONTROL_PROTOCOL_VERSION, + actual: version, + }); + } + let capabilities = envelope + .response + .response + .capabilities + .ok_or(CoshCoreCodecError::InitializeCapabilitiesMissing)?; + self.phase = CoshCoreProtocolPhase::Ready; + Ok(CoshCoreObservation::Initialized(capabilities)) + } + + fn decode_ready( + &mut self, + message_type: &str, + value: Value, + ) -> Result { + match message_type { + "system" => serde_json::from_value(value) + .map(CoshCoreObservation::System) + .map_err(Into::into), + "stream_event" => { + let message: WireStreamEnvelope = serde_json::from_value(value)?; + Ok(CoshCoreObservation::Stream(message.event)) + } + "assistant" => serde_json::from_value(value) + .map(CoshCoreObservation::Assistant) + .map_err(Into::into), + "user" => { + let message: WireUserOutput = serde_json::from_value(value)?; + Ok(CoshCoreObservation::ToolResults { + provider_session_id: message.provider_session_id, + results: message + .message + .content + .into_iter() + .map(|content| match content { + WireUserContent::ToolResult(result) => result, + }) + .collect(), + }) + } + "control_request" => { + decode_control_request(value).map(CoshCoreObservation::ControlRequest) + } + "control_response" => { + let message: WireGenericControlResponseEnvelope = serde_json::from_value(value)?; + if message.response.request_id == self.initialize_request_id + || message + .response + .response + .get("subtype") + .and_then(Value::as_str) + == Some("initialize") + { + return Err(CoshCoreCodecError::DuplicateInitializeResponse); + } + Ok(CoshCoreObservation::ControlResponse( + CoshCoreControlResponse { + request_id: message.response.request_id, + subtype: message.response.subtype, + body: message.response.response, + }, + )) + } + "registry_response" => { + let message: WireRegistryResponse = serde_json::from_value(value)?; + Ok(CoshCoreObservation::RegistryResponse { + request_id: message.request_id, + success: message.success, + data: message.data, + error: message.error, + }) + } + "result" => { + let result = serde_json::from_value(value)?; + self.phase = CoshCoreProtocolPhase::Terminal; + Ok(CoshCoreObservation::Result(result)) + } + other => Err(CoshCoreCodecError::UnknownMessageType(other.to_string())), + } + } + + fn invalid_phase(&self, operation: &'static str) -> CoshCoreCodecError { + CoshCoreCodecError::InvalidPhase { + operation, + phase: self.phase, + } + } +} + +fn decode_control_request( + value: Value, +) -> Result { + let message: WireControlRequestEnvelope = serde_json::from_value(value)?; + Ok(CoshCoreControlRequestEnvelope { + request_id: message.request_id, + request: message.request, + }) +} + +fn encode_frame( + value: &T, + max_frame_bytes: usize, +) -> Result { + let mut frame = serde_json::to_string(value)?; + if frame.len() > max_frame_bytes { + return Err(CoshCoreCodecError::FrameTooLarge { + limit: max_frame_bytes, + }); + } + frame.push('\n'); + Ok(frame) +} + +#[derive(Serialize)] +struct InitializeInput<'a> { + #[serde(rename = "type")] + message_type: &'static str, + request_id: &'a str, + request: InitializeRequest, +} + +#[derive(Serialize)] +struct InitializeRequest { + subtype: &'static str, + fire_session_start: bool, + protocol_version: u32, +} + +#[derive(Serialize)] +struct SimpleControlInput<'a> { + #[serde(rename = "type")] + message_type: &'static str, + request_id: &'a str, + request: SimpleControlRequest, +} + +#[derive(Serialize)] +struct SimpleControlRequest { + subtype: &'static str, +} + +#[derive(Serialize)] +struct UserInput<'a> { + #[serde(rename = "type")] + message_type: &'static str, + message: UserInputBody<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + shell_context: Option<&'a CoshCoreShellContext>, +} + +#[derive(Serialize)] +struct UserInputBody<'a> { + role: &'static str, + content: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + raw_user_input: Option<&'a str>, +} + +#[derive(Deserialize)] +struct WireControlResponseEnvelope { + response: WireInitializeResponse, +} + +#[derive(Deserialize)] +struct WireInitializeResponse { + subtype: String, + request_id: String, + response: WireInitializeBody, +} + +#[derive(Deserialize)] +struct WireInitializeBody { + subtype: String, + #[serde(default)] + protocol_version: Option, + #[serde(default)] + capabilities: Option, + #[serde(default)] + error: Option, +} + +#[derive(Deserialize)] +struct WireStreamEnvelope { + event: CoshCoreStreamEvent, +} + +#[derive(Deserialize)] +struct WireUserOutput { + #[serde(rename = "session_id")] + provider_session_id: String, + message: WireUserBody, +} + +#[derive(Deserialize)] +struct WireUserBody { + content: Vec, +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +enum WireUserContent { + #[serde(rename = "tool_result")] + ToolResult(CoshCoreToolResult), +} + +#[derive(Deserialize)] +struct WireControlRequestEnvelope { + request_id: String, + request: CoshCoreControlRequest, +} + +#[derive(Deserialize)] +struct WireGenericControlResponseEnvelope { + response: WireGenericControlResponse, +} + +#[derive(Deserialize)] +struct WireGenericControlResponse { + subtype: String, + request_id: String, + response: Value, +} + +#[derive(Deserialize)] +struct WireRegistryResponse { + request_id: String, + success: bool, + #[serde(default)] + data: Option, + #[serde(default)] + error: Option, +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/tests.rs new file mode 100644 index 0000000000..f241113d62 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/tests.rs @@ -0,0 +1,167 @@ +//! Focused private cosh-core JSONL codec tests. + +use super::*; +use serde_json::Value; + +fn initialized_codec() -> CoshCoreJsonlCodec { + let mut codec = CoshCoreJsonlCodec::new("gateway-init-1", 4096).unwrap(); + codec.initialize_frame(false).unwrap(); + let response = br#"{"type":"control_response","response":{"subtype":"success","request_id":"gateway-init-1","response":{"subtype":"initialize","protocol_version":1,"capabilities":{"can_handle_can_use_tool":true,"can_handle_host_executed_shell_tool_result":true,"can_handle_shell_evidence_tool":false,"can_handle_approval_receipt":true}}}}"#; + assert!(matches!( + codec.decode_frame(response).unwrap(), + CoshCoreObservation::Initialized(_) + )); + codec +} + +#[test] +fn initialize_is_explicitly_private_version_one() { + let mut codec = CoshCoreJsonlCodec::new("gateway-init-1", 4096).unwrap(); + + let frame = codec.initialize_frame(false).unwrap(); + let value: Value = serde_json::from_str(frame.trim()).unwrap(); + + assert_eq!(value["type"], "control_request"); + assert_eq!(value["request"]["subtype"], "initialize"); + assert_eq!(value["request"]["protocol_version"], 1); + assert_eq!(value["request"]["fire_session_start"], false); + assert_eq!(codec.phase(), CoshCoreProtocolPhase::AwaitingInitialize); +} + +#[test] +fn initialization_requires_exact_version_and_correlation() { + let mut codec = CoshCoreJsonlCodec::new("expected", 4096).unwrap(); + codec.initialize_frame(true).unwrap(); + let mismatched = br#"{"type":"control_response","response":{"subtype":"success","request_id":"other","response":{"subtype":"initialize","protocol_version":1,"capabilities":{}}}}"#; + + assert!(matches!( + codec.decode_frame(mismatched), + Err(CoshCoreCodecError::InitializeCorrelationMismatch) + )); + + let wrong_version = br#"{"type":"control_response","response":{"subtype":"success","request_id":"expected","response":{"subtype":"initialize","protocol_version":2,"capabilities":{}}}}"#; + assert!(matches!( + codec.decode_frame(wrong_version), + Err(CoshCoreCodecError::InitializeVersionMismatch { + required: 1, + actual: 2 + }) + )); +} + +#[test] +fn auth_bootstrap_is_only_control_request_allowed_before_ready() { + let mut codec = CoshCoreJsonlCodec::new("init", 4096).unwrap(); + codec.initialize_frame(true).unwrap(); + let auth = br#"{"type":"control_request","request_id":"auth-1","request":{"subtype":"auth_required","reason":"not_configured","providers":[]}}"#; + + assert!(matches!( + codec.decode_frame(auth).unwrap(), + CoshCoreObservation::ControlRequest(CoshCoreControlRequestEnvelope { + request: CoshCoreControlRequest::AuthRequired { .. }, + .. + }) + )); + assert_eq!(codec.phase(), CoshCoreProtocolPhase::AwaitingInitialize); +} + +#[test] +fn result_and_eof_produce_one_terminal_observation() { + let mut codec = initialized_codec(); + let result = br#"{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"provider-session"}"#; + + assert!(matches!( + codec.decode_frame(result).unwrap(), + CoshCoreObservation::Result(CoshCoreResult { + is_error: false, + .. + }) + )); + assert_eq!(codec.phase(), CoshCoreProtocolPhase::Terminal); + assert_eq!(codec.finish_stdout(), None); + assert!(matches!( + codec.decode_frame(result), + Err(CoshCoreCodecError::OutputAfterTerminal) + )); +} + +#[test] +fn eof_before_result_is_synthetic_terminal_once() { + let mut codec = initialized_codec(); + + assert_eq!( + codec.finish_stdout(), + Some(CoshCoreObservation::ProtocolEndedWithoutResult) + ); + assert_eq!(codec.finish_stdout(), None); +} + +#[test] +fn user_mapping_uses_provider_session_without_gateway_identity() { + let codec = initialized_codec(); + let frame = codec + .user_frame(&CoshCoreUserTurn { + content: "diagnose".to_string(), + provider_session_id: Some("provider-session".to_string()), + raw_user_input: Some("diagnose".to_string()), + shell_context: None, + }) + .unwrap(); + let value: Value = serde_json::from_str(frame.trim()).unwrap(); + + assert_eq!(value["type"], "user"); + assert_eq!(value["session_id"], "provider-session"); + assert_eq!(value["message"]["role"], "user"); +} + +#[test] +fn duplicate_initialize_response_is_rejected_after_readiness() { + let mut codec = initialized_codec(); + let duplicate = br#"{"type":"control_response","response":{"subtype":"success","request_id":"gateway-init-1","response":{"subtype":"initialize","protocol_version":1,"capabilities":{}}}}"#; + + assert!(matches!( + codec.decode_frame(duplicate), + Err(CoshCoreCodecError::DuplicateInitializeResponse) + )); +} + +#[test] +fn user_output_accepts_only_typed_tool_results() { + let mut codec = initialized_codec(); + let invalid = br#"{"type":"user","session_id":"provider-session","message":{"content":[{"type":"text","tool_use_id":"tool-1","is_error":false,"content":"not a tool result"}]}}"#; + + assert!(matches!( + codec.decode_frame(invalid), + Err(CoshCoreCodecError::Malformed(_)) + )); +} + +#[test] +fn user_output_maps_typed_tool_result() { + let mut codec = initialized_codec(); + let output = br#"{"type":"user","session_id":"provider-session","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false,"content":"done"}]}}"#; + + let observation = codec.decode_frame(output).unwrap(); + assert_eq!( + observation, + CoshCoreObservation::ToolResults { + provider_session_id: "provider-session".to_string(), + results: vec![CoshCoreToolResult { + tool_use_id: "tool-1".to_string(), + is_error: false, + content: "done".to_string(), + }], + } + ); +} + +#[test] +fn oversized_output_is_rejected_before_json_allocation() { + let mut codec = CoshCoreJsonlCodec::new("init", 8).unwrap(); + codec.initialize_frame(true).unwrap_err(); + assert_eq!(codec.phase(), CoshCoreProtocolPhase::Created); + assert!(matches!( + codec.decode_frame(b"123456789"), + Err(CoshCoreCodecError::FrameTooLarge { limit: 8 }) + )); +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/types.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/types.rs new file mode 100644 index 0000000000..c6a7169d38 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/cosh_core_jsonl/types.rs @@ -0,0 +1,462 @@ +//! Runtime-local types for the private cosh-core JSONL wire contract. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +/// Exact private Shell/Core control protocol version implemented by cosh-core. +pub const PRIVATE_COSH_CONTROL_PROTOCOL_VERSION: u32 = 1; + +/// Protocol negotiation and terminal state owned by one codec instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoshCoreProtocolPhase { + /// No initialize request has been encoded. + Created, + /// Initialize was sent; only its response or bounded auth bootstrap is valid. + AwaitingInitialize, + /// Exact-version negotiation succeeded and turn traffic is admissible. + Ready, + /// One result or synthetic EOF terminal was emitted. + Terminal, +} + +/// Private protocol capability snapshot returned by cosh-core initialization. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +pub struct CoshCoreCapabilities { + /// Core accepts `can_use_tool` control exchanges. + #[serde(default)] + pub can_handle_can_use_tool: bool, + /// Core accepts host-executed Shell tool results. + #[serde(default)] + pub can_handle_host_executed_shell_tool_result: bool, + /// Core accepts bounded Shell evidence responses. + #[serde(default)] + pub can_handle_shell_evidence_tool: bool, + /// Core accepts durable approval-ownership receipts. + #[serde(default)] + pub can_handle_approval_receipt: bool, +} + +/// One typed user turn encoded for the private cosh-core transport. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CoshCoreUserTurn { + /// Provider-facing prompt content. + pub content: String, + /// Optional provider session binding; not a Gateway Task or Agent identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_session_id: Option, + /// Original user text retained for current hook compatibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_user_input: Option, + /// Optional bounded compatibility context for a brokered profile. + #[serde(skip_serializing_if = "Option::is_none")] + pub shell_context: Option, +} + +/// Compatibility context accepted by the current private cosh-core protocol. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CoshCoreShellContext { + /// Pinned runtime workspace. + pub cwd: PathBuf, + /// Explicit bounded environment snapshot. + pub env: std::collections::BTreeMap, + /// Previous governed Shell execution status. + pub last_exit_code: i32, +} + +/// Typed observation produced from one private cosh-core output frame. +/// +/// These observations are intentionally runtime-local. A bridge must attach +/// public contract identities, ordering, fences, and causation separately. +#[derive(Debug, Clone, PartialEq)] +pub enum CoshCoreObservation { + /// Exact-version initialization succeeded. + Initialized(CoshCoreCapabilities), + /// Session metadata, status, or hook notification. + System(CoshCoreSystemMessage), + /// Ordered provider stream update. + Stream(CoshCoreStreamEvent), + /// Completed assistant message. + Assistant(CoshCoreAssistantMessage), + /// Completed tool results echoed by the core. + ToolResults { + /// Provider session associated with the message. + provider_session_id: String, + /// Tool results in wire order. + results: Vec, + }, + /// Core-initiated permission, question, auth, or evidence request. + ControlRequest(CoshCoreControlRequestEnvelope), + /// Correlated non-initialization management response. + ControlResponse(CoshCoreControlResponse), + /// Correlated registry response. + RegistryResponse { + /// Caller-provided private request identifier. + request_id: String, + /// Whether the registry operation succeeded. + success: bool, + /// Bounded response data. + data: Option, + /// Provider-safe failure message. + error: Option, + }, + /// Core-emitted turn terminal. Only one is accepted per codec lifecycle. + Result(CoshCoreResult), + /// Stdout ended before a core result; the bridge must fail/suspend the Run. + ProtocolEndedWithoutResult, +} + +/// Private cosh-core system output. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct CoshCoreSystemMessage { + /// Private system message subtype such as `init` or `status`. + pub subtype: String, + /// Provider session metadata when supplied. + #[serde(default, rename = "session_id")] + pub provider_session_id: Option, + /// Whether the provider session can be resumed. + #[serde(default)] + pub session_resumable: Option, + /// Provider model name. + #[serde(default)] + pub model: Option, + /// Advertised provider/core tools. + #[serde(default)] + pub tools: Option>, + /// Status or hook notification text. + #[serde(default)] + pub status: Option, + /// Hook name for hook notifications. + #[serde(default)] + pub hook_name: Option, + /// Provider tool-use correlation identifier. + #[serde(default)] + pub tool_use_id: Option, + /// Hook governance decision. + #[serde(default)] + pub decision: Option, +} + +/// One private cosh-core streaming update. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(tag = "type")] +pub enum CoshCoreStreamEvent { + /// Starts one provider message. + #[serde(rename = "message_start")] + MessageStart, + /// Starts a content block. + #[serde(rename = "content_block_start")] + ContentBlockStart { + /// Provider block index. + index: u32, + /// Initial block metadata. + content_block: CoshCoreContentBlockInfo, + }, + /// Appends one bounded content delta. + #[serde(rename = "content_block_delta")] + ContentBlockDelta { + /// Provider block index. + index: u32, + /// Provider delta payload. + delta: CoshCoreContentDelta, + }, + /// Completes a content block. + #[serde(rename = "content_block_stop")] + ContentBlockStop { + /// Provider block index. + index: u32, + }, + /// Completes one provider message. + #[serde(rename = "message_stop")] + MessageStop, +} + +/// Initial metadata for a private stream content block. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(tag = "type")] +pub enum CoshCoreContentBlockInfo { + /// Assistant text block. + #[serde(rename = "text")] + Text, + /// Provider thinking block. + #[serde(rename = "thinking")] + Thinking, + /// Provider tool-use block. + #[serde(rename = "tool_use")] + ToolUse { + /// Provider tool-use identifier. + id: String, + /// Provider tool name. + name: String, + }, +} + +/// Delta payload for a private stream content block. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(tag = "type")] +pub enum CoshCoreContentDelta { + /// Assistant text fragment. + #[serde(rename = "text_delta")] + TextDelta { + /// Text fragment. + text: String, + }, + /// Provider thinking fragment. + #[serde(rename = "thinking_delta")] + ThinkingDelta { + /// Thinking fragment. + thinking: String, + }, + /// Partial JSON tool input. + #[serde(rename = "input_json_delta")] + InputJsonDelta { + /// JSON fragment; completeness is established only by block stop. + partial_json: String, + }, +} + +/// Completed assistant output from the private core protocol. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct CoshCoreAssistantMessage { + /// Provider session associated with the message. + #[serde(rename = "session_id")] + pub provider_session_id: String, + /// Completed content blocks in provider order. + pub message: CoshCoreAssistantBody, +} + +/// Body of one completed assistant message. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct CoshCoreAssistantBody { + /// Completed content blocks. + pub content: Vec, +} + +/// Completed assistant content block. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(tag = "type")] +pub enum CoshCoreContentBlock { + /// Assistant text. + #[serde(rename = "text")] + Text { + /// Completed text. + text: String, + }, + /// Declared provider tool use. + #[serde(rename = "tool_use")] + ToolUse { + /// Provider tool-use identifier. + id: String, + /// Provider tool name. + name: String, + /// Typed tool input remains opaque until broker normalization. + input: Value, + }, +} + +/// Completed tool result emitted on the private user-message output path. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct CoshCoreToolResult { + /// Provider tool-use identifier. + pub tool_use_id: String, + /// Whether the tool result represents failure. + pub is_error: bool, + /// Bounded provider-facing result content. + pub content: String, +} + +/// Correlated private control request from cosh-core. +#[derive(Debug, Clone, PartialEq)] +pub struct CoshCoreControlRequestEnvelope { + /// Core-provided request identifier. + pub request_id: String, + /// Typed request payload. + pub request: CoshCoreControlRequest, +} + +/// Private control requests that require a bridge-owned response. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(tag = "subtype")] +pub enum CoshCoreControlRequest { + /// Requests policy evaluation for provider tool intent. + #[serde(rename = "can_use_tool")] + CanUseTool { + /// Provider tool name. + tool_name: String, + /// Tool input to canonicalize before broker evaluation. + input: Value, + /// Optional provider description. + #[serde(default)] + description: Option, + /// Provider tool-use identifier. + tool_use_id: String, + /// Optional durable audit correlation. + #[serde(default)] + audit_ref: Option, + /// Whether hooks independently require approval. + #[serde(default)] + hook_requires_approval: bool, + }, + /// Requests durable user input. + #[serde(rename = "ask_user")] + AskUser { + /// Question text. + question: String, + /// Option payload retained for higher-level normalization. + options: Vec, + /// Whether free text is accepted. + allow_free_text: bool, + /// Whether multiple options can be selected. + multi_select: bool, + }, + /// Requests credential bootstrap or reauthentication. + #[serde(rename = "auth_required")] + AuthRequired { + /// Stable private auth reason. + reason: String, + /// Provider-safe auth error. + #[serde(default)] + error_message: Option, + /// Credential schemas; secret values never appear here. + providers: Vec, + }, + /// Requests bounded evidence owned by a separate capability. + #[serde(rename = "shell_evidence")] + ShellEvidence { + /// Provider tool-use identifier. + tool_use_id: String, + /// Evidence operation. + action: String, + /// List bound. + #[serde(default)] + limit: Option, + /// Pagination cursor. + #[serde(default)] + cursor: Option, + /// Evidence output identifier. + #[serde(default)] + output_id: Option, + /// Read direction. + #[serde(default)] + direction: Option, + /// Read line bound. + #[serde(default)] + lines: Option, + /// Explicit compatibility flag subject to broker policy. + #[serde(default)] + bypass_recent_filter: Option, + }, +} + +/// Non-initialization private control response. +#[derive(Debug, Clone, PartialEq)] +pub struct CoshCoreControlResponse { + /// Private request identifier. + pub request_id: String, + /// Response subtype. + pub subtype: String, + /// Response body retained for management-path mapping. + pub body: Value, +} + +/// Core-emitted result for one provider turn. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct CoshCoreResult { + /// Optional private result subtype. + #[serde(default)] + pub subtype: Option, + /// Whether the provider/core classified the turn as failure. + pub is_error: bool, + /// Provider-facing result summary. + #[serde(default)] + pub result: Option, + /// Structured error summaries. + #[serde(default)] + pub errors: Option>, + /// Stable private core error code. + #[serde(default)] + pub error_code: Option, + /// Provider turn budget when reported. + #[serde(default)] + pub max_turns: Option, + /// Stable provider-session error code. + #[serde(default)] + pub session_error_code: Option, + /// Provider-session phase that failed. + #[serde(default)] + pub session_error_phase: Option, + /// Provider session binding returned by cosh-core. + #[serde(default, rename = "session_id")] + pub provider_session_id: Option, + /// Proposed environment change; not execution authority. + #[serde(default)] + pub env_delta: Option, + /// Core-reported turn duration. + #[serde(default)] + pub duration_ms: Option, +} + +/// Private codec failure; callers map this to public protocol errors. +#[derive(Debug, Error)] +pub enum CoshCoreCodecError { + /// Codec configuration was unsafe. + #[error("private cosh-core JSONL limit must be non-zero")] + InvalidLimit, + /// Operation was not valid in the current negotiation phase. + #[error("private cosh-core operation {operation} is invalid in phase {phase:?}")] + InvalidPhase { + /// Attempted operation. + operation: &'static str, + /// Current codec phase. + phase: CoshCoreProtocolPhase, + }, + /// Wire line exceeded its configured byte budget. + #[error("private cosh-core JSONL frame exceeds the {limit}-byte limit")] + FrameTooLarge { + /// Maximum accepted frame size. + limit: usize, + }, + /// Wire bytes were not UTF-8. + #[error("private cosh-core JSONL frame is not valid UTF-8")] + InvalidUtf8, + /// Empty lines are not protocol messages. + #[error("private cosh-core JSONL frame is empty")] + EmptyFrame, + /// JSON or typed payload deserialization failed. + #[error("malformed private cosh-core JSONL frame: {0}")] + Malformed(#[from] serde_json::Error), + /// Unknown top-level message types fail closed. + #[error("unknown private cosh-core output type {0:?}")] + UnknownMessageType(String), + /// Initialization received output outside its allowed bootstrap subset. + #[error("unexpected private cosh-core output {0:?} before initialization")] + UnexpectedBeforeInitialization(String), + /// Initialize response did not match the outstanding request. + #[error("private cosh-core initialize response correlation mismatch")] + InitializeCorrelationMismatch, + /// Peer rejected private protocol initialization. + #[error("private cosh-core initialize rejected: {0}")] + InitializeRejected(String), + /// Peer omitted exact private protocol version negotiation. + #[error("private cosh-core initialize response omitted protocol_version")] + InitializeVersionMissing, + /// Peer advertised an incompatible private protocol version. + #[error("private cosh-core protocol version {actual} does not match required {required}")] + InitializeVersionMismatch { + /// Required version. + required: u32, + /// Peer version. + actual: u32, + }, + /// Production bridge requires an explicit capability snapshot. + #[error("private cosh-core initialize response omitted capabilities")] + InitializeCapabilitiesMissing, + /// Output following a terminal result violates deterministic settlement. + #[error("private cosh-core emitted output after terminal result")] + OutputAfterTerminal, + /// A second initialize response cannot mutate the negotiated snapshot. + #[error("private cosh-core emitted a duplicate initialize response")] + DuplicateInitializeResponse, +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/process_group.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/process_group.rs new file mode 100644 index 0000000000..f01b44bc46 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/process_group.rs @@ -0,0 +1,83 @@ +//! Platform process-group isolation and signalling. + +use std::fmt; +use std::io; +use std::process::Command; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +#[cfg(unix)] +use nix::errno::Errno; +#[cfg(unix)] +use nix::sys::signal::{killpg, Signal}; +#[cfg(unix)] +use nix::unistd::Pid; + +/// Lifecycle operations used to isolate and terminate one runtime process group. +/// +/// Implementations are injected into the supervisor so lifecycle tests can +/// observe escalation without duplicating OS process ownership elsewhere. +pub trait ProcessGroupLifecycle: fmt::Debug + Send + Sync { + /// Configures a command before spawn so the child leads a dedicated group. + fn configure(&self, command: &mut Command); + + /// Sends a graceful termination signal to the complete process group. + /// + /// # Errors + /// + /// Returns an OS error when the group exists but cannot be signalled. + fn terminate(&self, process_group: u32) -> io::Result<()>; + + /// Sends an unconditional kill signal to the complete process group. + /// + /// # Errors + /// + /// Returns an OS error when the group exists but cannot be signalled. + fn kill(&self, process_group: u32) -> io::Result<()>; +} + +/// Native process-group implementation used by production supervisors. +#[derive(Debug, Default)] +pub struct PlatformProcessGroup; + +impl ProcessGroupLifecycle for PlatformProcessGroup { + fn configure(&self, command: &mut Command) { + #[cfg(unix)] + command.process_group(0); + } + + fn terminate(&self, process_group: u32) -> io::Result<()> { + signal_group(process_group, GroupSignal::Terminate) + } + + fn kill(&self, process_group: u32) -> io::Result<()> { + signal_group(process_group, GroupSignal::Kill) + } +} + +#[derive(Debug, Clone, Copy)] +enum GroupSignal { + Terminate, + Kill, +} + +#[cfg(unix)] +fn signal_group(process_group: u32, signal: GroupSignal) -> io::Result<()> { + let signal = match signal { + GroupSignal::Terminate => Signal::SIGTERM, + GroupSignal::Kill => Signal::SIGKILL, + }; + match killpg(Pid::from_raw(process_group as i32), signal) { + Ok(()) | Err(Errno::ESRCH) => Ok(()), + Err(error) => Err(io::Error::from_raw_os_error(error as i32)), + } +} + +#[cfg(not(unix))] +fn signal_group(_process_group: u32, _signal: GroupSignal) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "process-group signalling is unavailable on this platform", + )) +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/profile.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/profile.rs new file mode 100644 index 0000000000..d506fbe420 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/profile.rs @@ -0,0 +1,452 @@ +//! Allowlisted launch profiles for locally installed ACP v1 adapters. + +use std::collections::BTreeMap; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use super::{AcpV1BridgeError, AcpV1ClientConfig, AcpV1RuntimeBridge, RuntimeLaunchSpec}; + +const COMMON_ENVIRONMENT: &[&str] = &[ + "HOME", + "PATH", + "TMPDIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "XDG_STATE_HOME", +]; +const CODEX_ENVIRONMENT: &[&str] = &[ + "CODEX_API_KEY", + "CODEX_HOME", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_ORGANIZATION", + "OPENAI_PROJECT", +]; +const CLAUDE_ENVIRONMENT: &[&str] = &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR", +]; + +/// Stable identity of an ACP adapter supported by the first COSH profile set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AcpRuntimeProfileId { + /// Official ACP adapter backed by the Codex app server. + Codex, + /// Official ACP adapter backed by the Claude Agent SDK. + ClaudeCode, +} + +/// Immutable metadata for one built-in ACP adapter launch profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AcpRuntimeProfile { + id: AcpRuntimeProfileId, + display_name: &'static str, + executable_name: &'static str, + arguments: &'static [&'static str], + provider_environment: &'static [&'static str], +} + +const CODEX_PROFILE: AcpRuntimeProfile = AcpRuntimeProfile { + id: AcpRuntimeProfileId::Codex, + display_name: "Codex ACP adapter", + executable_name: "codex-acp", + arguments: &[], + provider_environment: CODEX_ENVIRONMENT, +}; +const CLAUDE_PROFILE: AcpRuntimeProfile = AcpRuntimeProfile { + id: AcpRuntimeProfileId::ClaudeCode, + display_name: "Claude Agent ACP adapter", + executable_name: "claude-agent-acp", + arguments: &[], + provider_environment: CLAUDE_ENVIRONMENT, +}; +const BUILT_IN_PROFILES: &[AcpRuntimeProfile] = &[CODEX_PROFILE, CLAUDE_PROFILE]; + +/// Returns the complete, fixed Phase-1 ACP runtime profile set. +#[must_use] +pub fn built_in_acp_runtime_profiles() -> &'static [AcpRuntimeProfile] { + BUILT_IN_PROFILES +} + +impl AcpRuntimeProfileId { + /// Returns the immutable launch policy associated with this identity. + #[must_use] + pub fn profile(self) -> &'static AcpRuntimeProfile { + match self { + Self::Codex => &CODEX_PROFILE, + Self::ClaudeCode => &CLAUDE_PROFILE, + } + } +} + +impl AcpRuntimeProfile { + /// Returns the stable profile identity. + #[must_use] + pub fn id(self) -> AcpRuntimeProfileId { + self.id + } + + /// Returns the human-readable adapter name. + #[must_use] + pub fn display_name(self) -> &'static str { + self.display_name + } + + /// Returns the only accepted executable basename for this profile. + #[must_use] + pub fn executable_name(self) -> &'static str { + self.executable_name + } + + /// Returns fixed adapter arguments. Prompts can never add process arguments. + #[must_use] + pub fn arguments(self) -> &'static [&'static str] { + self.arguments + } + + /// Returns names that may cross the cleared-environment boundary. + pub fn allowed_environment_names(self) -> impl Iterator { + COMMON_ENVIRONMENT + .iter() + .chain(self.provider_environment.iter()) + .copied() + } +} + +/// Inputs used to resolve one allowlisted local adapter process. +#[derive(Clone)] +pub struct AcpRuntimeProfileRequest { + /// Selected built-in adapter profile. + pub profile: AcpRuntimeProfileId, + /// Optional trusted local adapter path. The basename must match the profile. + /// + /// npm-style symlinks are accepted and pinned to their canonical target. + pub executable: Option, + /// Workspace to canonicalize and bind as the child working directory. + pub workspace: PathBuf, + /// Source environment filtered through the profile allowlist. + pub environment: BTreeMap, +} + +impl AcpRuntimeProfileRequest { + /// Captures the current process environment for later allowlist filtering. + #[must_use] + pub fn from_current_environment( + profile: AcpRuntimeProfileId, + executable: Option, + workspace: impl Into, + ) -> Self { + Self { + profile, + executable, + workspace: workspace.into(), + environment: env::vars_os().collect(), + } + } +} + +impl fmt::Debug for AcpRuntimeProfileRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AcpRuntimeProfileRequest") + .field("profile", &self.profile) + .field("executable", &self.executable) + .field("workspace", &self.workspace) + .field("environment_names", &self.environment.keys()) + .finish() + } +} + +/// Fully pinned adapter launch resolved from one built-in profile. +pub struct ResolvedAcpRuntimeProfile { + profile: AcpRuntimeProfileId, + executable: PathBuf, + workspace: PathBuf, + environment: BTreeMap, +} + +impl ResolvedAcpRuntimeProfile { + /// Returns the selected built-in profile identity. + #[must_use] + pub fn profile(&self) -> AcpRuntimeProfileId { + self.profile + } + + /// Returns the canonical absolute adapter executable. + #[must_use] + pub fn executable(&self) -> &Path { + &self.executable + } + + /// Returns the canonical absolute workspace. + #[must_use] + pub fn workspace(&self) -> &Path { + &self.workspace + } + + /// Returns allowed environment names without exposing their values. + pub fn environment_names(&self) -> impl Iterator { + self.environment.keys().map(OsString::as_os_str) + } + + /// Launches the pinned adapter with the ACP v1 runtime bridge. + /// + /// # Errors + /// + /// Returns ACP client configuration or supervised process launch failures. + pub fn launch( + &self, + client: AcpV1ClientConfig, + ) -> Result { + let profile = self.profile.profile(); + let mut spec = RuntimeLaunchSpec::new(&self.executable, &self.workspace); + spec.arguments = profile.arguments.iter().map(OsString::from).collect(); + spec.environment.clone_from(&self.environment); + AcpV1RuntimeBridge::launch(&spec, client).map_err(Into::into) + } +} + +impl fmt::Debug for ResolvedAcpRuntimeProfile { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedAcpRuntimeProfile") + .field("profile", &self.profile) + .field("executable", &self.executable) + .field("workspace", &self.workspace) + .field("environment_names", &self.environment.keys()) + .finish() + } +} + +/// Resolves allowlisted adapter profiles into immutable launch specifications. +#[derive(Debug, Default, Clone, Copy)] +pub struct AcpRuntimeProfileResolver; + +impl AcpRuntimeProfileResolver { + /// Resolves and validates one trusted local adapter and workspace without spawning. + /// + /// An explicit executable must be absolute. Without one, only absolute + /// directories in the supplied `PATH` are searched. Resolution never + /// downloads, installs, invokes a shell, or delegates to a package runner. + /// + /// # Errors + /// + /// Rejects missing, mismatched, non-regular, or non-executable adapter + /// targets and unavailable/non-directory workspaces. This resolver bounds + /// launch shape; it does not attest local package provenance. + pub fn resolve( + request: AcpRuntimeProfileRequest, + ) -> Result { + let profile = request.profile.profile(); + let executable = match request.executable { + Some(path) => resolve_explicit_executable(profile, &path)?, + None => resolve_from_path(profile, request.environment.get(OsStr::new("PATH")))?, + }; + let workspace = canonical_directory(&request.workspace)?; + let mut environment: BTreeMap = request + .environment + .into_iter() + .filter(|(name, _)| { + name.to_str() + .is_some_and(|name| profile.allowed_environment_names().any(|v| v == name)) + }) + .collect(); + if let Some(path) = environment.get_mut(OsStr::new("PATH")) { + *path = absolute_path_entries(path); + } + + Ok(ResolvedAcpRuntimeProfile { + profile: request.profile, + executable, + workspace, + environment, + }) + } +} + +fn absolute_path_entries(path: &OsStr) -> OsString { + let entries = env::split_paths(path) + .filter(|entry| entry.is_absolute()) + .collect::>(); + env::join_paths(entries).unwrap_or_default() +} + +/// Failure while pinning an ACP adapter profile before process launch. +#[derive(Debug, Error)] +pub enum AcpRuntimeProfileResolveError { + /// Explicit adapter paths cannot depend on a daemon working directory. + #[error("ACP adapter path must be absolute: {0}")] + ExecutableNotAbsolute(PathBuf), + /// A profile cannot be redirected to a different command. + #[error("ACP adapter basename {actual:?} does not match required {expected:?}")] + ExecutableNameMismatch { + /// Profile-pinned executable basename. + expected: &'static str, + /// Rejected configured basename. + actual: OsString, + }, + /// The profile executable was not found in an explicit absolute `PATH` entry. + #[error("ACP adapter executable {name:?} was not found in absolute PATH entries")] + ExecutableNotFound { + /// Profile-pinned executable basename. + name: &'static str, + }, + /// Filesystem metadata or canonicalization failed. + #[error("ACP adapter is unavailable at {path}: {source}")] + ExecutableUnavailable { + /// Adapter path that could not be inspected. + path: PathBuf, + /// Underlying filesystem failure. + #[source] + source: io::Error, + }, + /// The resolved target must be a regular file. + #[error("ACP adapter is not a regular file: {0}")] + ExecutableNotRegular(PathBuf), + /// Unix requires at least one executable permission bit. + #[error("ACP adapter is not executable: {0}")] + ExecutableNotExecutable(PathBuf), + /// Workspace canonicalization or metadata inspection failed. + #[error("ACP workspace is unavailable at {path}: {source}")] + WorkspaceUnavailable { + /// Workspace path that could not be inspected. + path: PathBuf, + /// Underlying filesystem failure. + #[source] + source: io::Error, + }, + /// ACP sessions require a directory workspace. + #[error("ACP workspace is not a directory: {0}")] + WorkspaceNotDirectory(PathBuf), +} + +/// Failure returned after a profile has resolved and launch is attempted. +#[derive(Debug, Error)] +pub enum AcpRuntimeProfileLaunchError { + /// ACP bridge initialization or supervised process launch failed. + #[error(transparent)] + Bridge(#[from] AcpV1BridgeError), +} + +fn resolve_explicit_executable( + profile: &AcpRuntimeProfile, + path: &Path, +) -> Result { + if !path.is_absolute() { + return Err(AcpRuntimeProfileResolveError::ExecutableNotAbsolute( + path.to_path_buf(), + )); + } + if path.file_name() != Some(OsStr::new(profile.executable_name)) { + return Err(AcpRuntimeProfileResolveError::ExecutableNameMismatch { + expected: profile.executable_name, + actual: path.file_name().unwrap_or_default().to_os_string(), + }); + } + canonical_executable(path) +} + +fn resolve_from_path( + profile: &AcpRuntimeProfile, + path: Option<&OsString>, +) -> Result { + let Some(path) = path else { + return Err(AcpRuntimeProfileResolveError::ExecutableNotFound { + name: profile.executable_name, + }); + }; + for directory in env::split_paths(path) { + if !directory.is_absolute() { + continue; + } + let candidate = directory.join(profile.executable_name); + match fs::symlink_metadata(&candidate) { + Ok(_) => return canonical_executable(&candidate), + Err(source) if source.kind() == io::ErrorKind::NotFound => continue, + Err(source) => { + return Err(AcpRuntimeProfileResolveError::ExecutableUnavailable { + path: candidate, + source, + }); + } + } + } + Err(AcpRuntimeProfileResolveError::ExecutableNotFound { + name: profile.executable_name, + }) +} + +fn canonical_executable(path: &Path) -> Result { + // npm installs command shims as symlinks. Canonicalize the trusted local + // profile path before launch so the child cannot later depend on the shim. + let canonical = fs::canonicalize(path).map_err(|source| { + AcpRuntimeProfileResolveError::ExecutableUnavailable { + path: path.to_path_buf(), + source, + } + })?; + let metadata = fs::metadata(&canonical).map_err(|source| { + AcpRuntimeProfileResolveError::ExecutableUnavailable { + path: canonical.clone(), + source, + } + })?; + if !metadata.is_file() { + return Err(AcpRuntimeProfileResolveError::ExecutableNotRegular( + canonical, + )); + } + if !is_executable(&metadata) { + return Err(AcpRuntimeProfileResolveError::ExecutableNotExecutable( + canonical, + )); + } + Ok(canonical) +} + +#[cfg(unix)] +fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_metadata: &fs::Metadata) -> bool { + true +} + +fn canonical_directory(path: &Path) -> Result { + let canonical = fs::canonicalize(path).map_err(|source| { + AcpRuntimeProfileResolveError::WorkspaceUnavailable { + path: path.to_path_buf(), + source, + } + })?; + let metadata = fs::metadata(&canonical).map_err(|source| { + AcpRuntimeProfileResolveError::WorkspaceUnavailable { + path: canonical.clone(), + source, + } + })?; + if !metadata.is_dir() { + return Err(AcpRuntimeProfileResolveError::WorkspaceNotDirectory( + canonical, + )); + } + Ok(canonical) +} + +#[cfg(test)] +#[path = "profile/tests.rs"] +mod tests; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/profile/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/profile/tests.rs new file mode 100644 index 0000000000..7eaeffd3ca --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/profile/tests.rs @@ -0,0 +1,228 @@ +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; + +use super::{ + built_in_acp_runtime_profiles, AcpRuntimeProfileId, AcpRuntimeProfileRequest, + AcpRuntimeProfileResolveError, AcpRuntimeProfileResolver, +}; + +fn executable(directory: &Path, name: &str) -> PathBuf { + let path = directory.join(name); + fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + } + path +} + +fn request( + profile: AcpRuntimeProfileId, + executable: Option, + workspace: &Path, + environment: BTreeMap, +) -> AcpRuntimeProfileRequest { + AcpRuntimeProfileRequest { + profile, + executable, + workspace: workspace.to_path_buf(), + environment, + } +} + +#[test] +fn built_in_profiles_pin_official_adapter_commands() { + let profiles = built_in_acp_runtime_profiles(); + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].id(), AcpRuntimeProfileId::Codex); + assert_eq!(profiles[0].executable_name(), "codex-acp"); + assert!(profiles[0].arguments().is_empty()); + assert_eq!(profiles[1].id(), AcpRuntimeProfileId::ClaudeCode); + assert_eq!(profiles[1].executable_name(), "claude-agent-acp"); + assert!(profiles[1].arguments().is_empty()); +} + +#[test] +fn rejects_explicit_command_spoofing_and_relative_paths() { + let root = TempDir::new().unwrap(); + let shell = executable(root.path(), "sh"); + let spoofed = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(shell), + root.path(), + BTreeMap::new(), + )); + assert!(matches!( + spoofed, + Err(AcpRuntimeProfileResolveError::ExecutableNameMismatch { .. }) + )); + + let relative = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(PathBuf::from("codex-acp")), + root.path(), + BTreeMap::new(), + )); + assert!(matches!( + relative, + Err(AcpRuntimeProfileResolveError::ExecutableNotAbsolute(_)) + )); +} + +#[test] +fn rejects_missing_and_non_executable_adapters() { + let root = TempDir::new().unwrap(); + let missing = root.path().join("codex-acp"); + let result = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(missing), + root.path(), + BTreeMap::new(), + )); + assert!(matches!( + result, + Err(AcpRuntimeProfileResolveError::ExecutableUnavailable { .. }) + )); + + let path = root.path().join("codex-acp"); + fs::write(&path, "not executable").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let result = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(path), + root.path(), + BTreeMap::new(), + )); + assert!(matches!( + result, + Err(AcpRuntimeProfileResolveError::ExecutableNotExecutable(_)) + )); + } +} + +#[cfg(unix)] +#[test] +fn accepts_and_pins_an_npm_style_adapter_symlink() { + use std::os::unix::fs::symlink; + + let root = TempDir::new().unwrap(); + let target = executable(root.path(), "arbitrary-runtime"); + let adapter = root.path().join("codex-acp"); + symlink(&target, &adapter).unwrap(); + + let resolved = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(adapter), + root.path(), + BTreeMap::new(), + )) + .unwrap(); + + assert_eq!(resolved.executable(), fs::canonicalize(target).unwrap()); +} + +#[test] +fn path_discovery_ignores_relative_entries_and_canonicalizes() { + let root = TempDir::new().unwrap(); + let bin = root.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let adapter = executable(&bin, "codex-acp"); + let path = env_path(&[PathBuf::from("relative-bin"), bin]); + let environment = BTreeMap::from([(OsString::from("PATH"), path)]); + + let resolved = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + None, + root.path(), + environment, + )) + .unwrap(); + + assert_eq!(resolved.executable(), fs::canonicalize(adapter).unwrap()); + let sanitized_path = resolved + .environment + .get(OsStr::new("PATH")) + .expect("PATH remains available to script adapters"); + assert!(std::env::split_paths(sanitized_path).all(|entry| entry.is_absolute())); +} + +#[test] +fn environment_is_filtered_per_profile_and_debug_is_redacted() { + let root = TempDir::new().unwrap(); + let adapter = executable(root.path(), "claude-agent-acp"); + let secret = "highly-sensitive-secret"; + let environment = BTreeMap::from([ + (OsString::from("HOME"), OsString::from("/safe/home")), + (OsString::from("PATH"), OsString::from("/safe/bin")), + ( + OsString::from("XDG_CONFIG_HOME"), + OsString::from("/safe/xdg"), + ), + (OsString::from("ANTHROPIC_API_KEY"), OsString::from(secret)), + ( + OsString::from("OPENAI_API_KEY"), + OsString::from("wrong-provider"), + ), + (OsString::from("LD_PRELOAD"), OsString::from("/unsafe.so")), + ]); + let request = request( + AcpRuntimeProfileId::ClaudeCode, + Some(adapter), + root.path(), + environment, + ); + assert!(!format!("{request:?}").contains(secret)); + + let resolved = AcpRuntimeProfileResolver::resolve(request).unwrap(); + let names: Vec<_> = resolved.environment_names().collect(); + assert!(names.contains(&OsStr::new("HOME"))); + assert!(names.contains(&OsStr::new("PATH"))); + assert!(names.contains(&OsStr::new("XDG_CONFIG_HOME"))); + assert!(names.contains(&OsStr::new("ANTHROPIC_API_KEY"))); + assert!(!names.contains(&OsStr::new("OPENAI_API_KEY"))); + assert!(!names.contains(&OsStr::new("LD_PRELOAD"))); + assert!(!format!("{resolved:?}").contains(secret)); +} + +#[test] +fn workspace_is_canonical_and_must_be_a_directory() { + let root = TempDir::new().unwrap(); + let adapter = executable(root.path(), "codex-acp"); + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + let aliased_workspace = workspace.join("..").join("workspace"); + + let resolved = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(adapter.clone()), + &aliased_workspace, + BTreeMap::new(), + )) + .unwrap(); + assert_eq!(resolved.workspace(), fs::canonicalize(workspace).unwrap()); + + let file = root.path().join("not-a-workspace"); + fs::write(&file, "data").unwrap(); + let result = AcpRuntimeProfileResolver::resolve(request( + AcpRuntimeProfileId::Codex, + Some(adapter), + &file, + BTreeMap::new(), + )); + assert!(matches!( + result, + Err(AcpRuntimeProfileResolveError::WorkspaceNotDirectory(_)) + )); +} + +fn env_path(entries: &[PathBuf]) -> OsString { + std::env::join_paths(entries).unwrap() +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver.rs new file mode 100644 index 0000000000..b2088e4061 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver.rs @@ -0,0 +1,688 @@ +//! Responsive single-owner ACP session orchestration over supervised stdio. + +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +use super::{ + AcpV1BridgeError, AcpV1BridgeRead, AcpV1ClientConfig, AcpV1Observation, + AcpV1PermissionDecision, AcpV1RequestId, AcpV1RuntimeBridge, ProcessTerminal, + RuntimeLaunchSpec, +}; + +const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(10); +const COMMAND_CAPACITY: usize = 8; +const CONTROL_CAPACITY: usize = 1; +const EVENT_CAPACITY: usize = 32; +const MAX_TERMINAL_DETAIL_BYTES: usize = 4 * 1024; + +/// Deadlines and immutable launch inputs for one local ACP session. +#[derive(Debug, Clone)] +pub struct AcpSessionDriverConfig { + /// Direct supervised Agent launch specification. + pub launch: RuntimeLaunchSpec, + /// ACP client identity and frame bound. + pub client: AcpV1ClientConfig, + /// Canonical workspace bound to the single Agent session. + pub workspace: PathBuf, + /// Optional workspace roots passed only when the Agent advertises support. + pub additional_directories: Vec, + /// Maximum wait for initialize and `session/new` responses. + pub initialize_timeout: Duration, + /// Maximum lifetime of one active prompt. + pub prompt_timeout: Duration, + /// TERM grace before KILL escalation during settlement. + pub shutdown_grace: Duration, + /// Maximum caller wait for actor acknowledgements. + pub command_timeout: Duration, +} + +impl AcpSessionDriverConfig { + /// Builds a local single-session configuration with conservative deadlines. + #[must_use] + pub fn new( + launch: RuntimeLaunchSpec, + client: AcpV1ClientConfig, + workspace: impl Into, + ) -> Self { + Self { + launch, + client, + workspace: workspace.into(), + additional_directories: Vec::new(), + initialize_timeout: Duration::from_secs(10), + prompt_timeout: Duration::from_secs(30 * 60), + shutdown_grace: Duration::from_secs(2), + command_timeout: Duration::from_secs(10), + } + } +} + +/// One bounded event delivered by the ACP session actor. +#[derive(Debug)] +pub enum AcpSessionEvent { + /// Validated protocol observation in wire order. + Observation(AcpV1Observation), + /// The sole terminal event for this driver generation. + Terminal(AcpSessionTerminal), +} + +/// Stable reason for the sole session-driver terminal event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcpSessionTerminalKind { + /// Caller requested orderly shutdown. + Shutdown, + /// Independent control handle cancelled the active prompt. + Cancelled, + /// Protocol, transport, deadline, or actor coordination failed closed. + Failed, +} + +/// Final driver result emitted after the runtime child has been reaped. +#[derive(Debug)] +pub struct AcpSessionTerminal { + /// Stable terminal classification. + pub kind: AcpSessionTerminalKind, + /// Bounded diagnostic without protocol payloads or secrets. + pub detail: Option, + /// Reaped process terminal when cleanup returned it. + pub process: Option, +} + +/// Failure returned to a session-driver caller. +#[derive(Debug, Error)] +pub enum AcpSessionDriverError { + /// Launching the supervised bridge failed before an actor was exposed. + #[error(transparent)] + Bridge(#[from] AcpV1BridgeError), + /// A command was rejected in the current driver state. + #[error("ACP session command {operation} is invalid while state is {state}")] + InvalidState { + /// Requested operation. + operation: &'static str, + /// Compact actor state name. + state: &'static str, + }, + /// A mandatory response did not arrive before its explicit deadline. + #[error("ACP {operation} exceeded its deadline")] + Deadline { + /// Timed-out operation. + operation: &'static str, + }, + /// The actor or bounded queue is unavailable. + #[error("ACP session actor is unavailable")] + ActorUnavailable, + /// The independent cancellation slot already contains a request. + #[error("ACP cancellation is already pending")] + CancellationPending, + /// An event consumer failed to keep up with the bounded stream. + #[error("ACP observation queue reached its bound")] + ObservationBackpressure, + /// Independent control cancelled a deadline-bound operation. + #[error("ACP operation was cancelled")] + Cancelled, +} + +type Reply = SyncSender>; + +#[derive(Debug)] +enum DriverCommand { + Initialize(Reply), + OpenSession(Reply), + Prompt { + text: String, + reply: Reply, + }, + Permission { + request_id: AcpV1RequestId, + decision: AcpV1PermissionDecision, + reply: Reply, + }, + Shutdown(Reply), +} + +/// Cloneable cancellation path that is independent from ordinary commands. +#[derive(Debug, Clone)] +pub struct AcpSessionControl { + cancel: SyncSender<()>, +} + +impl AcpSessionControl { + /// Enqueues cancellation without waiting for Agent stdout or actor work. + /// + /// # Errors + /// + /// Returns when cancellation is already pending or the actor exited. + pub fn cancel(&self) -> Result<(), AcpSessionDriverError> { + match self.cancel.try_send(()) { + Ok(()) => Ok(()), + Err(TrySendError::Full(())) => Err(AcpSessionDriverError::CancellationPending), + Err(TrySendError::Disconnected(())) => Err(AcpSessionDriverError::ActorUnavailable), + } + } +} + +/// Public handle for one actor-owned ACP connection and session. +#[derive(Debug)] +pub struct AcpSessionDriver { + commands: SyncSender, + events: Receiver, + terminal: Receiver, + control: AcpSessionControl, + actor: Option>, + command_timeout: Duration, +} + +impl AcpSessionDriver { + /// Launches the Agent and starts the sole bridge owner thread. + /// + /// # Errors + /// + /// Returns bridge launch or actor thread creation failures. + pub fn launch(config: AcpSessionDriverConfig) -> Result { + let bridge = AcpV1RuntimeBridge::launch(&config.launch, config.client.clone())?; + let (command_sender, command_receiver) = mpsc::sync_channel(COMMAND_CAPACITY); + let (cancel_sender, cancel_receiver) = mpsc::sync_channel(CONTROL_CAPACITY); + let (event_sender, event_receiver) = mpsc::sync_channel(EVENT_CAPACITY); + let (terminal_sender, terminal_receiver) = mpsc::sync_channel(1); + let command_timeout = config.command_timeout; + let actor = thread::Builder::new() + .name("cosh-acp-session".to_owned()) + .spawn(move || { + run_actor( + bridge, + config, + command_receiver, + cancel_receiver, + event_sender, + terminal_sender, + ) + }) + .map_err(|_| AcpSessionDriverError::ActorUnavailable)?; + Ok(Self { + commands: command_sender, + events: event_receiver, + terminal: terminal_receiver, + control: AcpSessionControl { + cancel: cancel_sender, + }, + actor: Some(actor), + command_timeout, + }) + } + + /// Returns an independent cancellation handle. + #[must_use] + pub fn control(&self) -> AcpSessionControl { + self.control.clone() + } + + /// Negotiates ACP wire version 1 within the initialization deadline. + pub fn initialize(&self) -> Result<(), AcpSessionDriverError> { + self.request(DriverCommand::Initialize) + } + + /// Opens the single configured canonical workspace session. + pub fn open_session(&self) -> Result<(), AcpSessionDriverError> { + self.request(DriverCommand::OpenSession) + } + + /// Starts the only active text prompt. + pub fn prompt(&self, text: impl Into) -> Result<(), AcpSessionDriverError> { + let text = text.into(); + self.request(move |reply| DriverCommand::Prompt { text, reply }) + } + + /// Answers one correlated permission callback exactly once. + pub fn answer_permission( + &self, + request_id: AcpV1RequestId, + decision: AcpV1PermissionDecision, + ) -> Result<(), AcpSessionDriverError> { + self.request(move |reply| DriverCommand::Permission { + request_id, + decision, + reply, + }) + } + + /// Receives one event before `timeout` expires. + pub fn receive_timeout(&self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(RecvTimeoutError::Timeout); + } + match self + .events + .recv_timeout(remaining.min(CONTROL_POLL_INTERVAL)) + { + Ok(event) => return Ok(event), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + return self + .terminal + .recv_timeout(remaining) + .map(AcpSessionEvent::Terminal); + } + } + } + } + + /// Requests orderly process settlement. + pub fn shutdown(&self) -> Result<(), AcpSessionDriverError> { + self.request(DriverCommand::Shutdown) + } + + fn request(&self, build: F) -> Result<(), AcpSessionDriverError> + where + F: FnOnce(Reply) -> DriverCommand, + { + let (reply_sender, reply_receiver) = mpsc::sync_channel(1); + let deadline = Instant::now() + self.command_timeout; + let mut command = build(reply_sender); + loop { + match self.commands.try_send(command) { + Ok(()) => break, + Err(TrySendError::Full(returned)) => { + command = returned; + if Instant::now() >= deadline { + let _ = self.control.cancel(); + return Err(AcpSessionDriverError::Deadline { + operation: "command queue", + }); + } + thread::sleep(Duration::from_millis(1)); + } + Err(TrySendError::Disconnected(_)) => { + return Err(AcpSessionDriverError::ActorUnavailable); + } + } + } + let remaining = deadline.saturating_duration_since(Instant::now()); + match reply_receiver.recv_timeout(remaining) { + Ok(result) => result, + Err(_) => { + let _ = self.control.cancel(); + Err(AcpSessionDriverError::Deadline { + operation: "command acknowledgement", + }) + } + } + } +} + +impl Drop for AcpSessionDriver { + fn drop(&mut self) { + let _ = self.control.cancel(); + if let Some(actor) = self.actor.take() { + let _ = actor.join(); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActorState { + Created, + Initialized, + SessionOpen, + PromptActive, + Terminal, +} + +impl ActorState { + fn name(self) -> &'static str { + match self { + Self::Created => "created", + Self::Initialized => "initialized", + Self::SessionOpen => "session-open", + Self::PromptActive => "prompt-active", + Self::Terminal => "terminal", + } + } +} + +fn run_actor( + mut bridge: AcpV1RuntimeBridge, + config: AcpSessionDriverConfig, + commands: Receiver, + cancel: Receiver<()>, + events: SyncSender, + terminal: SyncSender, +) { + let mut state = ActorState::Created; + let mut prompt_deadline = None; + loop { + match cancel.try_recv() { + Ok(()) => { + settle_cancel(&mut bridge, &config, &terminal, state); + break; + } + Err(TryRecvError::Disconnected | TryRecvError::Empty) => {} + } + + match commands.try_recv() { + Ok(command) => { + if handle_command( + command, + &mut bridge, + &config, + &events, + &terminal, + &cancel, + &mut state, + &mut prompt_deadline, + ) { + break; + } + continue; + } + Err(TryRecvError::Disconnected) => { + settle_cancel(&mut bridge, &config, &terminal, state); + break; + } + Err(TryRecvError::Empty) => {} + } + + if state != ActorState::PromptActive { + thread::sleep(CONTROL_POLL_INTERVAL); + continue; + } + if prompt_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + fail_terminal( + &mut bridge, + &config, + &terminal, + AcpSessionDriverError::Deadline { + operation: "prompt", + }, + ); + break; + } + match bridge.read_observation_timeout(CONTROL_POLL_INTERVAL) { + Ok(AcpV1BridgeRead::TimedOut) => {} + Ok(AcpV1BridgeRead::Observation(observation)) => { + if let Err(error) = settle_unsupported(&mut bridge, &observation) { + fail_terminal(&mut bridge, &config, &terminal, error); + break; + } + let finished = matches!( + observation, + AcpV1Observation::PromptFinished { .. } + | AcpV1Observation::RequestFailed { .. } + ); + if emit_observation(&events, observation).is_err() { + fail_terminal( + &mut bridge, + &config, + &terminal, + AcpSessionDriverError::ObservationBackpressure, + ); + break; + } + if finished { + state = ActorState::SessionOpen; + prompt_deadline = None; + } + } + Err(error) => { + fail_terminal(&mut bridge, &config, &terminal, error.into()); + break; + } + } + } +} + +fn handle_command( + command: DriverCommand, + bridge: &mut AcpV1RuntimeBridge, + config: &AcpSessionDriverConfig, + events: &SyncSender, + terminal_events: &SyncSender, + cancel: &Receiver<()>, + state: &mut ActorState, + prompt_deadline: &mut Option, +) -> bool { + let (reply, result, terminal) = match command { + DriverCommand::Initialize(reply) => { + let result = require_state(*state, ActorState::Created, "initialize").and_then(|()| { + bridge.send_initialize()?; + wait_for( + bridge, + events, + cancel, + config.initialize_timeout, + "initialize", + |observation| matches!(observation, AcpV1Observation::Initialized { .. }), + )?; + *state = ActorState::Initialized; + Ok(()) + }); + (reply, result, false) + } + DriverCommand::OpenSession(reply) => { + let result = + require_state(*state, ActorState::Initialized, "open_session").and_then(|()| { + bridge.send_new_session( + config.workspace.clone(), + config.additional_directories.clone(), + )?; + wait_for( + bridge, + events, + cancel, + config.initialize_timeout, + "session/new", + |observation| matches!(observation, AcpV1Observation::SessionOpened { .. }), + )?; + *state = ActorState::SessionOpen; + Ok(()) + }); + (reply, result, false) + } + DriverCommand::Prompt { text, reply } => { + let result = require_state(*state, ActorState::SessionOpen, "prompt").and_then(|()| { + bridge.send_prompt(text)?; + *state = ActorState::PromptActive; + *prompt_deadline = Some(Instant::now() + config.prompt_timeout); + Ok(()) + }); + (reply, result, false) + } + DriverCommand::Permission { + request_id, + decision, + reply, + } => { + let result = require_state(*state, ActorState::PromptActive, "answer_permission") + .and_then(|()| { + bridge.send_permission_decision(&request_id, decision)?; + Ok(()) + }); + (reply, result, false) + } + DriverCommand::Shutdown(reply) => { + let result = settle( + bridge, + config, + terminal_events, + AcpSessionTerminalKind::Shutdown, + None, + ); + *state = ActorState::Terminal; + (reply, result, true) + } + }; + let fatal = result.as_ref().err().and_then(|error| match error { + AcpSessionDriverError::InvalidState { .. } => None, + AcpSessionDriverError::Cancelled => { + Some((AcpSessionTerminalKind::Cancelled, error.to_string())) + } + error => Some((AcpSessionTerminalKind::Failed, error.to_string())), + }); + let _ = reply.send(result); + if terminal { + return true; + } + if let Some((kind, detail)) = fatal { + let _ = settle(bridge, config, terminal_events, kind, Some(detail)); + *state = ActorState::Terminal; + return true; + } + false +} + +fn require_state( + actual: ActorState, + expected: ActorState, + operation: &'static str, +) -> Result<(), AcpSessionDriverError> { + if actual == expected { + Ok(()) + } else { + Err(AcpSessionDriverError::InvalidState { + operation, + state: actual.name(), + }) + } +} + +fn wait_for( + bridge: &mut AcpV1RuntimeBridge, + events: &SyncSender, + cancel: &Receiver<()>, + timeout: Duration, + operation: &'static str, + expected: impl Fn(&AcpV1Observation) -> bool, +) -> Result<(), AcpSessionDriverError> { + let deadline = Instant::now() + timeout; + loop { + match cancel.try_recv() { + Ok(()) => return Err(AcpSessionDriverError::Cancelled), + Err(TryRecvError::Disconnected | TryRecvError::Empty) => {} + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(AcpSessionDriverError::Deadline { operation }); + } + match bridge.read_observation_timeout(remaining.min(CONTROL_POLL_INTERVAL))? { + AcpV1BridgeRead::TimedOut => {} + AcpV1BridgeRead::Observation(observation) => { + settle_unsupported(bridge, &observation)?; + let matched = expected(&observation); + emit_observation(events, observation)?; + if matched { + return Ok(()); + } + } + } + } +} + +fn settle_unsupported( + bridge: &mut AcpV1RuntimeBridge, + observation: &AcpV1Observation, +) -> Result<(), AcpSessionDriverError> { + if let AcpV1Observation::UnsupportedClientRequest { request_id, .. } = observation { + bridge.reject_unsupported_request(request_id)?; + } + Ok(()) +} + +fn emit_observation( + events: &SyncSender, + observation: AcpV1Observation, +) -> Result<(), AcpSessionDriverError> { + events + .try_send(AcpSessionEvent::Observation(observation)) + .map_err(|_| AcpSessionDriverError::ObservationBackpressure) +} + +fn settle_cancel( + bridge: &mut AcpV1RuntimeBridge, + config: &AcpSessionDriverConfig, + terminal: &SyncSender, + state: ActorState, +) { + let detail = if state == ActorState::PromptActive { + bridge.send_cancel().err().map(|error| error.to_string()) + } else { + None + }; + let _ = settle( + bridge, + config, + terminal, + AcpSessionTerminalKind::Cancelled, + detail, + ); +} + +fn fail_terminal( + bridge: &mut AcpV1RuntimeBridge, + config: &AcpSessionDriverConfig, + terminal: &SyncSender, + error: AcpSessionDriverError, +) { + let _ = settle( + bridge, + config, + terminal, + AcpSessionTerminalKind::Failed, + Some(error.to_string()), + ); +} + +fn settle( + bridge: &mut AcpV1RuntimeBridge, + config: &AcpSessionDriverConfig, + terminal_events: &SyncSender, + kind: AcpSessionTerminalKind, + detail: Option, +) -> Result<(), AcpSessionDriverError> { + let shutdown = bridge.shutdown(config.shutdown_grace); + let (process, cleanup_error) = match &shutdown { + Ok(process) => (process.clone(), None), + Err(error) => ( + bridge.poll_terminal().ok().flatten(), + Some(error.to_string()), + ), + }; + let detail = match (detail, cleanup_error) { + (Some(detail), Some(cleanup)) => Some(format!("{detail}; cleanup failed: {cleanup}")), + (Some(detail), None) => Some(detail), + (None, Some(cleanup)) => Some(format!("cleanup failed: {cleanup}")), + (None, None) => None, + } + .map(|detail| bounded_detail(&detail)); + // The dedicated one-shot slot reserves terminal delivery even when a + // consumer stopped draining the bounded observation stream. + let _ = terminal_events.try_send(AcpSessionTerminal { + kind, + detail, + process, + }); + match shutdown { + Ok(_) => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn bounded_detail(detail: &str) -> String { + if detail.len() <= MAX_TERMINAL_DETAIL_BYTES { + return detail.to_owned(); + } + let mut end = MAX_TERMINAL_DETAIL_BYTES; + while !detail.is_char_boundary(end) { + end -= 1; + } + detail[..end].to_owned() +} + +#[cfg(test)] +#[path = "session_driver/tests.rs"] +mod tests; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver/tests.rs new file mode 100644 index 0000000000..9ca903e9cd --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/session_driver/tests.rs @@ -0,0 +1,302 @@ +//! Fake-Agent coverage for responsive ACP session orchestration. + +use std::time::{Duration, Instant}; + +use super::*; + +const FRAME_LIMIT: usize = 16 * 1024; + +#[cfg(unix)] +fn driver(script: &str, workspace: &tempfile::TempDir) -> AcpSessionDriver { + let mut launch = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + launch.arguments = vec!["-c".into(), script.into()]; + let mut config = AcpSessionDriverConfig::new( + launch, + AcpV1ClientConfig::new("cosh-ng", "0.15.0", FRAME_LIMIT), + workspace.path(), + ); + config.initialize_timeout = Duration::from_secs(2); + config.prompt_timeout = Duration::from_secs(2); + config.shutdown_grace = Duration::from_millis(50); + config.command_timeout = Duration::from_secs(3); + AcpSessionDriver::launch(config).unwrap() +} + +fn observation(driver: &AcpSessionDriver) -> AcpV1Observation { + match driver.receive_timeout(Duration::from_secs(2)).unwrap() { + AcpSessionEvent::Observation(observation) => observation, + AcpSessionEvent::Terminal(terminal) => { + panic!( + "unexpected terminal: {:?} {:?}", + terminal.kind, terminal.detail + ) + } + } +} + +#[cfg(unix)] +#[test] +fn driver_streams_one_prompt_and_settles_once() { + let workspace = tempfile::tempdir().unwrap(); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + case "$step" in + 1) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{}}}' ;; + 2) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"session-1"}}' ;; + 3) + printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}' + printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-3","result":{"stopReason":"end_turn"}}' + ;; + esac +done +"#; + let driver = driver(script, &workspace); + + driver.initialize().unwrap(); + assert!(matches!( + observation(&driver), + AcpV1Observation::Initialized { .. } + )); + driver.open_session().unwrap(); + assert!(matches!( + observation(&driver), + AcpV1Observation::SessionOpened { .. } + )); + driver.prompt("hello").unwrap(); + assert!(matches!( + observation(&driver), + AcpV1Observation::SessionUpdate { .. } + )); + assert!(matches!( + observation(&driver), + AcpV1Observation::PromptFinished { .. } + )); + driver.shutdown().unwrap(); + let AcpSessionEvent::Terminal(terminal) = + driver.receive_timeout(Duration::from_secs(2)).unwrap() + else { + panic!("expected terminal") + }; + assert_eq!(terminal.kind, AcpSessionTerminalKind::Shutdown); + assert!(terminal.process.is_some()); + assert!(matches!( + driver.receive_timeout(Duration::from_millis(20)), + Err(RecvTimeoutError::Disconnected | RecvTimeoutError::Timeout) + )); +} + +#[cfg(unix)] +#[test] +fn independent_cancel_reaps_silent_agent() { + let workspace = tempfile::tempdir().unwrap(); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + case "$step" in + 1) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{}}}' ;; + 2) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"session-1"}}' ;; + 3) while :; do sleep 1; done ;; + esac +done +"#; + let driver = driver(script, &workspace); + driver.initialize().unwrap(); + observation(&driver); + driver.open_session().unwrap(); + observation(&driver); + driver.prompt("wait").unwrap(); + + let started = Instant::now(); + driver.control().cancel().unwrap(); + let AcpSessionEvent::Terminal(terminal) = + driver.receive_timeout(Duration::from_secs(2)).unwrap() + else { + panic!("expected terminal") + }; + assert_eq!(terminal.kind, AcpSessionTerminalKind::Cancelled); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(terminal.process.is_some()); +} + +#[cfg(unix)] +#[test] +fn cancel_settles_pending_permission_before_reap() { + let workspace = tempfile::tempdir().unwrap(); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + case "$step" in + 1) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{}}}' ;; + 2) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"session-1"}}' ;; + 3) printf '%s\n' '{"jsonrpc":"2.0","id":41,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"tool-1","title":"Run"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"}]}}' ;; + esac +done +"#; + let driver = driver(script, &workspace); + driver.initialize().unwrap(); + observation(&driver); + driver.open_session().unwrap(); + observation(&driver); + driver.prompt("permission").unwrap(); + let AcpV1Observation::PermissionRequested(request) = observation(&driver) else { + panic!("expected permission request") + }; + assert_eq!(request.request_id, AcpV1RequestId::Number(41)); + + driver.control().cancel().unwrap(); + let AcpSessionEvent::Terminal(terminal) = + driver.receive_timeout(Duration::from_secs(2)).unwrap() + else { + panic!("expected terminal") + }; + assert_eq!(terminal.kind, AcpSessionTerminalKind::Cancelled); + assert!( + terminal.detail.is_none(), + "cancel frames should encode cleanly" + ); +} + +#[cfg(unix)] +#[test] +fn unsupported_callback_is_rejected_by_the_actor() { + let workspace = tempfile::tempdir().unwrap(); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + case "$step" in + 1) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{}}}' ;; + 2) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"session-1"}}' ;; + 3) printf '%s\n' '{"jsonrpc":"2.0","id":77,"method":"fs/read_text_file","params":{"sessionId":"session-1","path":"/etc/passwd"}}' ;; + 4) + printf '%s\n' "$line" | grep -q '"code":-32601' || exit 9 + printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-3","result":{"stopReason":"end_turn"}}' + ;; + esac +done +"#; + let driver = driver(script, &workspace); + driver.initialize().unwrap(); + observation(&driver); + driver.open_session().unwrap(); + observation(&driver); + driver.prompt("unsupported").unwrap(); + assert!(matches!( + observation(&driver), + AcpV1Observation::UnsupportedClientRequest { .. } + )); + assert!(matches!( + observation(&driver), + AcpV1Observation::PromptFinished { .. } + )); + driver.shutdown().unwrap(); +} + +#[cfg(unix)] +#[test] +fn malformed_initialize_fails_closed_with_one_terminal() { + let workspace = tempfile::tempdir().unwrap(); + let driver = driver( + "read -r line; printf '%s\\n' 'not-json'; sleep 60", + &workspace, + ); + + assert!(matches!( + driver.initialize(), + Err(AcpSessionDriverError::Bridge(_)) + )); + let AcpSessionEvent::Terminal(terminal) = + driver.receive_timeout(Duration::from_secs(2)).unwrap() + else { + panic!("expected terminal") + }; + assert_eq!(terminal.kind, AcpSessionTerminalKind::Failed); + assert!(terminal.detail.is_some()); + assert!(terminal.process.is_some()); +} + +#[cfg(unix)] +#[test] +fn permission_decision_is_single_use() { + let workspace = tempfile::tempdir().unwrap(); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + case "$step" in + 1) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{}}}' ;; + 2) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"session-1"}}' ;; + 3) printf '%s\n' '{"jsonrpc":"2.0","id":41,"method":"session/request_permission","params":{"sessionId":"session-1","toolCall":{"toolCallId":"tool-1","title":"Run"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"}]}}' ;; + 4) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-3","result":{"stopReason":"end_turn"}}' ;; + esac +done +"#; + let driver = driver(script, &workspace); + driver.initialize().unwrap(); + observation(&driver); + driver.open_session().unwrap(); + observation(&driver); + driver.prompt("permission").unwrap(); + let AcpV1Observation::PermissionRequested(request) = observation(&driver) else { + panic!("expected permission request") + }; + driver + .answer_permission( + request.request_id.clone(), + AcpV1PermissionDecision::Selected { + option_id: "allow".to_owned(), + }, + ) + .unwrap(); + assert!(driver + .answer_permission(request.request_id, AcpV1PermissionDecision::Cancelled) + .is_err()); + driver.shutdown().unwrap(); +} + +#[cfg(unix)] +#[test] +fn terminal_is_delivered_after_buffered_observations() { + let workspace = tempfile::tempdir().unwrap(); + let script = r#" +step=0 +while IFS= read -r line; do + step=$((step + 1)) + case "$step" in + 1) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-1","result":{"protocolVersion":1,"agentCapabilities":{}}}' ;; + 2) printf '%s\n' '{"jsonrpc":"2.0","id":"cosh-acp-2","result":{"sessionId":"session-1"}}' ;; + 3) + i=0 + while [ "$i" -lt 40 ]; do + printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"chunk"}}}}' + i=$((i + 1)) + done + ;; + esac +done +"#; + let driver = driver(script, &workspace); + driver.initialize().unwrap(); + observation(&driver); + driver.open_session().unwrap(); + observation(&driver); + driver.prompt("overflow").unwrap(); + std::thread::sleep(Duration::from_millis(100)); + + let mut observations = 0; + loop { + match driver.receive_timeout(Duration::from_secs(2)).unwrap() { + AcpSessionEvent::Observation(_) => observations += 1, + AcpSessionEvent::Terminal(terminal) => { + assert_eq!(terminal.kind, AcpSessionTerminalKind::Failed); + break; + } + } + } + assert_eq!(observations, EVENT_CAPACITY); + assert!(driver.receive_timeout(Duration::from_millis(20)).is_err()); +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor.rs new file mode 100644 index 0000000000..c09645cde0 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor.rs @@ -0,0 +1,738 @@ +//! Single-owner lifecycle for one local Agent Runtime child process. + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use thiserror::Error; +use wait_timeout::ChildExt; + +use super::bounded_io::{ + BoundedLineChannel, BoundedLineError, BoundedLineRead, BoundedWriteChannel, StderrCollector, + StderrSnapshot, +}; +use super::process_group::{PlatformProcessGroup, ProcessGroupLifecycle}; + +const MAX_STDERR_CAPACITY: usize = 1024 * 1024; +const MAX_STDOUT_LINE_BYTES: usize = 1024 * 1024; +const MAX_ENVIRONMENT_ENTRIES: usize = 256; +const MAX_ENVIRONMENT_VALUE_BYTES: usize = 64 * 1024; +const MAX_STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Configuration used to start a supervised runtime without invoking a shell. +#[derive(Debug, Clone)] +pub struct RuntimeLaunchSpec { + /// Approved absolute executable path. + pub program: PathBuf, + /// Arguments passed directly to the executable. + pub arguments: Vec, + /// Pinned absolute workspace used as the child working directory. + pub working_directory: PathBuf, + /// Explicit child environment after the inherited environment is cleared. + pub environment: BTreeMap, + /// Maximum retained stderr tail in bytes. + pub stderr_capacity: usize, + /// Maximum accepted stdout JSONL frame in bytes. + pub stdout_line_limit: usize, + /// Maximum time to enqueue and flush one stdin frame. + pub stdin_write_timeout: Duration, +} + +impl RuntimeLaunchSpec { + /// Builds a launch specification with conservative I/O bounds. + pub fn new(program: impl Into, working_directory: impl Into) -> Self { + Self { + program: program.into(), + arguments: Vec::new(), + working_directory: working_directory.into(), + environment: BTreeMap::new(), + stderr_capacity: 64 * 1024, + stdout_line_limit: 256 * 1024, + stdin_write_timeout: Duration::from_secs(5), + } + } + + /// Validates fields that must be settled before any child is created. + /// + /// # Errors + /// + /// Rejects non-absolute executables/workspaces, unsafe workspaces, invalid + /// environment entries, and unbounded I/O settings. + pub fn validate(&self) -> Result<(), RuntimeLaunchError> { + if !self.program.is_absolute() { + return Err(RuntimeLaunchError::ProgramNotAbsolute(self.program.clone())); + } + if !self.working_directory.is_absolute() { + return Err(RuntimeLaunchError::WorkspaceNotAbsolute( + self.working_directory.clone(), + )); + } + + let metadata = fs::metadata(&self.working_directory).map_err(|source| { + RuntimeLaunchError::WorkspaceUnavailable { + path: self.working_directory.clone(), + source, + } + })?; + if !metadata.is_dir() { + return Err(RuntimeLaunchError::WorkspaceNotDirectory( + self.working_directory.clone(), + )); + } + + validate_bound("stderr_capacity", self.stderr_capacity, MAX_STDERR_CAPACITY)?; + validate_bound( + "stdout_line_limit", + self.stdout_line_limit, + MAX_STDOUT_LINE_BYTES, + )?; + if self.stdin_write_timeout.is_zero() || self.stdin_write_timeout > MAX_STDIN_WRITE_TIMEOUT + { + return Err(RuntimeLaunchError::InvalidWriteTimeout { + actual: self.stdin_write_timeout, + maximum: MAX_STDIN_WRITE_TIMEOUT, + }); + } + if self.environment.len() > MAX_ENVIRONMENT_ENTRIES { + return Err(RuntimeLaunchError::TooManyEnvironmentEntries { + actual: self.environment.len(), + maximum: MAX_ENVIRONMENT_ENTRIES, + }); + } + for (name, value) in &self.environment { + validate_environment_name(name)?; + if os_str_bytes(value) > MAX_ENVIRONMENT_VALUE_BYTES { + return Err(RuntimeLaunchError::EnvironmentValueTooLarge { + name: name.to_string_lossy().into_owned(), + maximum: MAX_ENVIRONMENT_VALUE_BYTES, + }); + } + } + Ok(()) + } +} + +fn validate_bound( + field: &'static str, + actual: usize, + maximum: usize, +) -> Result<(), RuntimeLaunchError> { + if actual == 0 || actual > maximum { + return Err(RuntimeLaunchError::InvalidBound { + field, + actual, + maximum, + }); + } + Ok(()) +} + +fn validate_environment_name(name: &OsStr) -> Result<(), RuntimeLaunchError> { + let rendered = name.to_string_lossy(); + if rendered.is_empty() || rendered.contains('=') || rendered.contains('\0') { + return Err(RuntimeLaunchError::InvalidEnvironmentName( + rendered.into_owned(), + )); + } + Ok(()) +} + +#[cfg(unix)] +fn os_str_bytes(value: &OsStr) -> usize { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().len() +} + +#[cfg(not(unix))] +fn os_str_bytes(value: &OsStr) -> usize { + value.to_string_lossy().len() +} + +/// Launch validation failure detected before spawn. +#[derive(Debug, Error)] +pub enum RuntimeLaunchError { + /// Executables are resolved by policy before they reach the supervisor. + #[error("runtime program must be an absolute path: {0}")] + ProgramNotAbsolute(PathBuf), + /// Runtime workspaces must be pinned, not dependent on daemon cwd. + #[error("runtime workspace must be an absolute path: {0}")] + WorkspaceNotAbsolute(PathBuf), + /// Workspace metadata could not be read. + #[error("runtime workspace is unavailable at {path}: {source}")] + WorkspaceUnavailable { + /// Requested workspace. + path: PathBuf, + /// Underlying filesystem failure. + #[source] + source: io::Error, + }, + /// Workspace exists but is not a directory. + #[error("runtime workspace is not a directory: {0}")] + WorkspaceNotDirectory(PathBuf), + /// An I/O bound was zero or exceeded the hard safety ceiling. + #[error("invalid {field} {actual}; expected 1..={maximum}")] + InvalidBound { + /// Configuration field. + field: &'static str, + /// Rejected value. + actual: usize, + /// Hard safety ceiling. + maximum: usize, + }, + /// The explicit environment exceeded its entry budget. + #[error("runtime environment has {actual} entries; maximum is {maximum}")] + TooManyEnvironmentEntries { + /// Rejected entry count. + actual: usize, + /// Maximum allowed entry count. + maximum: usize, + }, + /// An environment name could not be passed safely to `Command`. + #[error("invalid runtime environment name: {0:?}")] + InvalidEnvironmentName(String), + /// One environment value exceeded its bound. + #[error("runtime environment value for {name:?} exceeds {maximum} bytes")] + EnvironmentValueTooLarge { + /// Environment key associated with the rejected value. + name: String, + /// Maximum allowed value size. + maximum: usize, + }, + /// Stdin writes need a finite non-zero deadline. + #[error("invalid stdin write timeout {actual:?}; expected 0 < timeout <= {maximum:?}")] + InvalidWriteTimeout { + /// Rejected timeout. + actual: Duration, + /// Hard safety ceiling. + maximum: Duration, + }, +} + +/// Observable supervisor lifecycle for one process generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeState { + /// No process has been launched. + Idle, + /// Launch validation passed and spawn is in progress. + Starting, + /// Child pipes are owned and protocol initialization may begin. + Initializing, + /// The protocol bridge marked initialization successful. + Ready, + /// Graceful shutdown or kill escalation is in progress. + Stopping, + /// The child was reaped and its sole process terminal was materialized. + Exited, +} + +/// Outcome of a deadline-bounded stdout poll. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimeFrameRead { + /// One complete protocol frame was received. + Frame(String), + /// The Agent closed stdout. + Eof, + /// No frame arrived within the requested duration. + TimedOut, +} + +/// Stable process-level exit classification; this is not a Task terminal event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessExit { + /// The runtime returned an ordinary platform exit code. + Code(i32), + /// The runtime was terminated by a signal on Unix. + Signal(i32), + /// The platform did not expose an exit code or signal. + Unknown, +} + +/// The sole process terminal produced after the supervised child is reaped. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcessTerminal { + /// Reaped platform exit classification. + pub exit: ProcessExit, + /// Bounded stderr diagnostics collected before settlement. + pub stderr: StderrSnapshot, +} + +/// Runtime supervisor failure. +#[derive(Debug, Error)] +pub enum RuntimeSupervisorError { + /// Launch validation failed before the state changed. + #[error(transparent)] + Launch(#[from] RuntimeLaunchError), + /// The requested operation is invalid in the current lifecycle state. + #[error("runtime operation {operation} is invalid while state is {state:?}")] + InvalidState { + /// Requested lifecycle operation. + operation: &'static str, + /// Current lifecycle state. + state: RuntimeState, + }, + /// Creating or controlling the child process failed. + #[error("runtime process operation failed: {0}")] + Process(#[from] io::Error), + /// Runtime stdout violated its framing bound. + #[error(transparent)] + Stdout(#[from] BoundedLineError), + /// Process-group signalling failed after the direct child was cleaned up. + #[error("failed to send {signal} to runtime process group; direct child was killed and reaped: {source}")] + ProcessGroupSignal { + /// Signal operation that failed. + signal: &'static str, + /// Underlying platform failure. + #[source] + source: io::Error, + }, +} + +/// Sole owner of one runtime child, its pipes, process group, and reap result. +#[derive(Debug)] +pub struct RuntimeSupervisor { + state: RuntimeState, + child: Option, + stdin: Option, + stdout: Option, + stderr: Option, + process_group: Arc, + process_group_id: Option, + terminal: Option, + terminal_delivered: bool, + stdin_write_timeout: Duration, +} + +impl Default for RuntimeSupervisor { + fn default() -> Self { + Self::new() + } +} + +impl RuntimeSupervisor { + /// Builds an idle supervisor using the native process-group implementation. + pub fn new() -> Self { + Self::with_process_group(Arc::new(PlatformProcessGroup)) + } + + /// Builds an idle supervisor with an injected lifecycle implementation. + pub fn with_process_group(process_group: Arc) -> Self { + Self { + state: RuntimeState::Idle, + child: None, + stdin: None, + stdout: None, + stderr: None, + process_group, + process_group_id: None, + terminal: None, + terminal_delivered: false, + stdin_write_timeout: Duration::from_secs(5), + } + } + + /// Returns the current process lifecycle state. + pub fn state(&self) -> RuntimeState { + self.state + } + + /// Validates and starts one direct child in a dedicated process group. + /// + /// # Errors + /// + /// Returns launch validation, pipe setup, thread creation, or spawn errors. + /// A failed launch owns no child and returns to `Idle`. + pub fn launch(&mut self, spec: &RuntimeLaunchSpec) -> Result<(), RuntimeSupervisorError> { + if self.state != RuntimeState::Idle { + return Err(RuntimeSupervisorError::InvalidState { + operation: "launch", + state: self.state, + }); + } + spec.validate()?; + self.state = RuntimeState::Starting; + + let launch_result = self.launch_validated(spec); + if launch_result.is_err() { + self.state = RuntimeState::Idle; + } + launch_result + } + + fn launch_validated(&mut self, spec: &RuntimeLaunchSpec) -> Result<(), RuntimeSupervisorError> { + let mut command = Command::new(&spec.program); + command + .args(&spec.arguments) + .current_dir(&spec.working_directory) + .env_clear() + .envs(&spec.environment) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + self.process_group.configure(&mut command); + + let mut child = command.spawn()?; + let process_group_id = child.id(); + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + cleanup_partial_child(&self.process_group, &mut child, process_group_id); + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stdin pipe unavailable", + ) + .into()); + } + }; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + cleanup_partial_child(&self.process_group, &mut child, process_group_id); + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stdout pipe unavailable", + ) + .into()); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + cleanup_partial_child(&self.process_group, &mut child, process_group_id); + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "runtime stderr pipe unavailable", + ) + .into()); + } + }; + + let collector = match StderrCollector::spawn(stderr, spec.stderr_capacity) { + Ok(collector) => collector, + Err(error) => { + cleanup_partial_child(&self.process_group, &mut child, process_group_id); + return Err(error.into()); + } + }; + + let stdin = match BoundedWriteChannel::spawn(stdin) { + Ok(channel) => channel, + Err(error) => { + cleanup_partial_child(&self.process_group, &mut child, process_group_id); + let _ = collector.finish(); + return Err(error.into()); + } + }; + let stdout = match BoundedLineChannel::spawn(stdout, spec.stdout_line_limit) { + Ok(channel) => channel, + Err(error) => { + cleanup_partial_child(&self.process_group, &mut child, process_group_id); + stdin.finish(); + let _ = collector.finish(); + return Err(error.into()); + } + }; + self.stdin = Some(stdin); + self.stdout = Some(stdout); + self.stderr = Some(collector); + self.process_group_id = Some(process_group_id); + self.child = Some(child); + self.terminal = None; + self.terminal_delivered = false; + self.stdin_write_timeout = spec.stdin_write_timeout; + self.state = RuntimeState::Initializing; + Ok(()) + } + + /// Marks successful protocol negotiation without changing process ownership. + /// + /// # Errors + /// + /// Returns an invalid-state error unless the child is initializing. + pub fn mark_ready(&mut self) -> Result<(), RuntimeSupervisorError> { + if self.state != RuntimeState::Initializing { + return Err(RuntimeSupervisorError::InvalidState { + operation: "mark_ready", + state: self.state, + }); + } + self.state = RuntimeState::Ready; + Ok(()) + } + + /// Writes one already-encoded protocol frame and flushes it. + /// + /// # Errors + /// + /// Returns an invalid-state error before launch/after exit or an I/O error + /// when the child closed its input. + pub fn write_frame(&mut self, frame: &str) -> Result<(), RuntimeSupervisorError> { + if !matches!(self.state, RuntimeState::Initializing | RuntimeState::Ready) { + return Err(RuntimeSupervisorError::InvalidState { + operation: "write_frame", + state: self.state, + }); + } + let stdin = self.stdin.as_ref().ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "runtime stdin pipe unavailable") + })?; + let mut bytes = frame.as_bytes().to_vec(); + if !frame.ends_with('\n') { + bytes.push(b'\n'); + } + stdin.write_timeout(bytes, self.stdin_write_timeout)?; + Ok(()) + } + + /// Reads one bounded protocol line from runtime stdout. + /// + /// # Errors + /// + /// Returns invalid-state, I/O, invalid UTF-8, or oversized-frame errors. + pub fn read_frame(&mut self) -> Result, RuntimeSupervisorError> { + loop { + match self.read_frame_timeout(Duration::from_secs(60))? { + RuntimeFrameRead::Frame(frame) => return Ok(Some(frame)), + RuntimeFrameRead::Eof => return Ok(None), + RuntimeFrameRead::TimedOut => {} + } + } + } + + /// Waits at most `timeout` for one bounded protocol line. + /// + /// # Errors + /// + /// Returns invalid-state, I/O, invalid UTF-8, or oversized-frame errors. + pub fn read_frame_timeout( + &mut self, + timeout: Duration, + ) -> Result { + if !matches!( + self.state, + RuntimeState::Initializing | RuntimeState::Ready | RuntimeState::Stopping + ) { + return Err(RuntimeSupervisorError::InvalidState { + operation: "read_frame", + state: self.state, + }); + } + let outcome = self + .stdout + .as_mut() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "runtime stdout pipe unavailable") + })? + .read_timeout(timeout)?; + Ok(match outcome { + BoundedLineRead::Line(frame) => RuntimeFrameRead::Frame(frame), + BoundedLineRead::Eof => RuntimeFrameRead::Eof, + BoundedLineRead::TimedOut => RuntimeFrameRead::TimedOut, + }) + } + + /// Returns a current bounded stderr snapshot without waiting for exit. + pub fn stderr_snapshot(&self) -> Option { + self.stderr.as_ref().map(StderrCollector::snapshot) + } + + /// Polls for process exit and delivers the process terminal at most once. + /// + /// # Errors + /// + /// Returns an invalid-state error before launch or an OS wait error. + pub fn poll_terminal(&mut self) -> Result, RuntimeSupervisorError> { + if self.state == RuntimeState::Idle { + return Err(RuntimeSupervisorError::InvalidState { + operation: "poll_terminal", + state: self.state, + }); + } + if self.state == RuntimeState::Exited { + return Ok(self.take_terminal()); + } + + let status = self + .child + .as_mut() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "runtime child unavailable"))? + .try_wait()?; + if let Some(status) = status { + self.settle(status); + } + Ok(self.take_terminal()) + } + + /// Sends TERM, waits for the grace period, escalates to KILL, and reaps. + /// + /// The returned process terminal is `None` only if it was already + /// delivered by `poll_terminal`. + /// + /// # Errors + /// + /// Returns invalid-state or OS signalling/wait errors. Drop still attempts + /// unconditional cleanup after an error. + pub fn shutdown( + &mut self, + grace: Duration, + ) -> Result, RuntimeSupervisorError> { + if self.state == RuntimeState::Idle { + return Err(RuntimeSupervisorError::InvalidState { + operation: "shutdown", + state: self.state, + }); + } + if self.state == RuntimeState::Exited { + return Ok(self.take_terminal()); + } + self.state = RuntimeState::Stopping; + if let Some(stdin) = self.stdin.take() { + stdin.finish(); + } + + if let Some(process_group_id) = self.process_group_id { + if let Err(source) = self.process_group.terminate(process_group_id) { + let status = self.kill_direct_child_and_reap()?; + self.settle(status); + return Err(RuntimeSupervisorError::ProcessGroupSignal { + signal: "TERM", + source, + }); + } + } + let child = self + .child + .as_mut() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "runtime child unavailable"))?; + let status = match child.wait_timeout(grace)? { + Some(status) => status, + None => { + if let Some(process_group_id) = self.process_group_id { + if let Err(group_error) = self.process_group.kill(process_group_id) { + let status = self.kill_direct_child_and_reap()?; + self.settle(status); + return Err(RuntimeSupervisorError::ProcessGroupSignal { + signal: "KILL", + source: group_error, + }); + } + } else { + child.kill()?; + } + child.wait()? + } + }; + self.settle(status); + Ok(self.take_terminal()) + } + + fn kill_direct_child_and_reap(&mut self) -> Result { + let child = self + .child + .as_mut() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "runtime child unavailable"))?; + match child.kill() { + Ok(()) => child.wait().map_err(Into::into), + Err(kill_error) => match child.try_wait()? { + Some(status) => Ok(status), + None => Err(kill_error.into()), + }, + } + } + + fn settle(&mut self, status: ExitStatus) { + // A runtime may leave descendants after its leader exits. The group is + // still the supervisor's ownership boundary, so settle it before + // publishing the only terminal observation. + if let Some(process_group_id) = self.process_group_id { + let _ = self.process_group.kill(process_group_id); + } + if let Some(stdin) = self.stdin.take() { + stdin.finish(); + } + if let Some(stdout) = self.stdout.take() { + stdout.finish(); + } + self.child.take(); + let stderr = self + .stderr + .take() + .map(StderrCollector::finish) + .unwrap_or_else(empty_stderr); + self.terminal = Some(ProcessTerminal { + exit: classify_exit(status), + stderr, + }); + self.state = RuntimeState::Exited; + } + + fn take_terminal(&mut self) -> Option { + if self.terminal_delivered { + return None; + } + let terminal = self.terminal.clone()?; + self.terminal_delivered = true; + Some(terminal) + } +} + +impl Drop for RuntimeSupervisor { + fn drop(&mut self) { + let Some(mut child) = self.child.take() else { + return; + }; + if let Some(process_group_id) = self.process_group_id { + let _ = self.process_group.kill(process_group_id); + } + let _ = child.kill(); + let _ = child.wait(); + if let Some(stdin) = self.stdin.take() { + stdin.finish(); + } + if let Some(stdout) = self.stdout.take() { + stdout.finish(); + } + if let Some(stderr) = self.stderr.take() { + let _ = stderr.finish(); + } + } +} + +fn empty_stderr() -> StderrSnapshot { + StderrSnapshot { + tail: String::new(), + discarded_bytes: 0, + read_error: None, + } +} + +fn classify_exit(status: ExitStatus) -> ProcessExit { + if let Some(code) = status.code() { + return ProcessExit::Code(code); + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return ProcessExit::Signal(signal); + } + } + ProcessExit::Unknown +} + +fn cleanup_partial_child( + process_group: &Arc, + child: &mut Child, + process_group_id: u32, +) { + let _ = process_group.kill(process_group_id); + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests; diff --git a/src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor/tests.rs new file mode 100644 index 0000000000..7de93991de --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/runtime/supervisor/tests.rs @@ -0,0 +1,156 @@ +//! Focused supervisor lifecycle and cleanup tests. + +use std::io; +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use tempfile::tempdir; + +use super::*; + +#[derive(Debug, Default)] +struct TermFailingProcessGroup { + terminate_calls: AtomicUsize, + kill_calls: AtomicUsize, +} + +impl ProcessGroupLifecycle for TermFailingProcessGroup { + fn configure(&self, command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + } + + fn terminate(&self, _process_group: u32) -> io::Result<()> { + self.terminate_calls.fetch_add(1, Ordering::SeqCst); + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected TERM failure", + )) + } + + fn kill(&self, _process_group: u32) -> io::Result<()> { + self.kill_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn launch_validation_rejects_relative_program_before_state_change() { + let workspace = tempdir().unwrap(); + let spec = RuntimeLaunchSpec::new("sh", workspace.path()); + let mut supervisor = RuntimeSupervisor::new(); + + assert!(matches!( + supervisor.launch(&spec), + Err(RuntimeSupervisorError::Launch( + RuntimeLaunchError::ProgramNotAbsolute(_) + )) + )); + assert_eq!(supervisor.state(), RuntimeState::Idle); +} + +#[cfg(unix)] +#[test] +fn reaps_once_and_retains_bounded_stderr_tail() { + let workspace = tempdir().unwrap(); + let mut spec = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + spec.arguments = vec![ + "-c".into(), + "printf 'ready\\n'; printf '0123456789' >&2; exit 7".into(), + ]; + spec.stderr_capacity = 5; + let mut supervisor = RuntimeSupervisor::new(); + + supervisor.launch(&spec).unwrap(); + assert_eq!(supervisor.state(), RuntimeState::Initializing); + supervisor.mark_ready().unwrap(); + assert_eq!(supervisor.read_frame().unwrap().as_deref(), Some("ready")); + + let deadline = Instant::now() + Duration::from_secs(2); + let terminal = loop { + if let Some(terminal) = supervisor.poll_terminal().unwrap() { + break terminal; + } + assert!(Instant::now() < deadline, "child did not exit"); + thread::sleep(Duration::from_millis(5)); + }; + assert_eq!(terminal.exit, ProcessExit::Code(7)); + assert_eq!(terminal.stderr.tail, "56789"); + assert_eq!(terminal.stderr.discarded_bytes, 5); + assert_eq!(supervisor.poll_terminal().unwrap(), None); +} + +#[cfg(unix)] +#[test] +fn shutdown_escalates_and_reaps_term_ignoring_child() { + let workspace = tempdir().unwrap(); + let mut spec = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + spec.arguments = vec![ + "-c".into(), + "trap '' TERM; printf 'ready\\n'; while :; do sleep 1; done".into(), + ]; + let mut supervisor = RuntimeSupervisor::new(); + + supervisor.launch(&spec).unwrap(); + assert_eq!(supervisor.read_frame().unwrap().as_deref(), Some("ready")); + let terminal = supervisor + .shutdown(Duration::from_millis(20)) + .unwrap() + .unwrap(); + + assert_eq!(terminal.exit, ProcessExit::Signal(9)); + assert_eq!(supervisor.state(), RuntimeState::Exited); + assert_eq!(supervisor.poll_terminal().unwrap(), None); +} + +#[cfg(unix)] +#[test] +fn stdin_write_deadline_keeps_shutdown_available() { + let workspace = tempdir().unwrap(); + let mut spec = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + spec.arguments = vec!["-c".into(), "sleep 60".into()]; + spec.stdin_write_timeout = Duration::from_millis(30); + let mut supervisor = RuntimeSupervisor::new(); + supervisor.launch(&spec).unwrap(); + + let frame = "x".repeat(256 * 1024); + assert!(matches!( + supervisor.write_frame(&frame), + Err(RuntimeSupervisorError::Process(ref error)) + if error.kind() == io::ErrorKind::TimedOut + )); + assert!(supervisor + .shutdown(Duration::from_millis(30)) + .unwrap() + .is_some()); + assert_eq!(supervisor.state(), RuntimeState::Exited); +} + +#[cfg(unix)] +#[test] +fn term_group_failure_still_kills_reaps_and_settles_once() { + let workspace = tempdir().unwrap(); + let mut spec = RuntimeLaunchSpec::new("/bin/sh", workspace.path()); + spec.arguments = vec!["-c".into(), "printf 'ready\\n'; while :; do :; done".into()]; + let process_group = Arc::new(TermFailingProcessGroup::default()); + let mut supervisor = RuntimeSupervisor::with_process_group(process_group.clone()); + + supervisor.launch(&spec).unwrap(); + assert_eq!(supervisor.read_frame().unwrap().as_deref(), Some("ready")); + let error = supervisor.shutdown(Duration::from_secs(1)).unwrap_err(); + + assert!(matches!( + error, + RuntimeSupervisorError::ProcessGroupSignal { signal: "TERM", .. } + )); + assert_eq!(process_group.terminate_calls.load(Ordering::SeqCst), 1); + assert_eq!(supervisor.state(), RuntimeState::Exited); + let terminal = supervisor.poll_terminal().unwrap().unwrap(); + assert_eq!(terminal.exit, ProcessExit::Signal(9)); + assert_eq!(supervisor.poll_terminal().unwrap(), None); +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/storage.rs b/src/cosh-ng/crates/cosh-gateway/src/storage.rs new file mode 100644 index 0000000000..8e11b30b8e --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/storage.rs @@ -0,0 +1,93 @@ +//! SQLite-backed durable storage for the Gateway task plane. +//! +//! The store owns one write connection and commits task events, projections, +//! command receipts, and Outbox intents in one immediate transaction. + +mod schema; +mod sqlite; +mod task_store; + +pub use sqlite::SqliteTaskStore; +pub use task_store::{CommitOutcome, CommitReceipt, OutboxIntent, TaskCommit}; + +use std::path::PathBuf; + +use thiserror::Error; + +use crate::task::AggregateError; + +/// Fail-closed storage errors exposed to Task coordination. +#[derive(Debug, Error)] +pub enum StoreError { + /// The configured database path or a companion file is unsafe. + #[error("unsafe Gateway database path {path}: {message}")] + UnsafePath { + /// Path rejected before or during database open. + path: PathBuf, + /// Bounded developer-oriented reason. + message: String, + }, + /// The database uses a schema newer than this binary understands. + #[error("Gateway database schema {found} is newer than supported schema {supported}")] + NewerSchema { + /// Schema version read from the database. + found: u32, + /// Highest version supported by this binary. + supported: u32, + }, + /// An already-applied migration does not match this binary. + #[error("Gateway migration {version} checksum mismatch")] + MigrationChecksum { + /// Migration version whose content changed. + version: u32, + }, + /// A Task command reused an idempotency key with another digest. + #[error("idempotency key was already used with a different command digest")] + IdempotencyConflict, + /// A command observed a Task revision different from its precondition. + #[error("task revision conflict: expected {expected}, found {actual}")] + RevisionConflict { + /// Revision supplied by the command. + expected: u64, + /// Current durable revision. + actual: u64, + }, + /// A requested Task does not exist. + #[error("task not found")] + TaskNotFound, + /// A commit batch violates Task or Outbox invariants. + #[error("invalid Gateway task commit: {message}")] + InvalidCommit { + /// Bounded developer-oriented reason. + message: String, + }, + /// A committed Task stream violates reducer invariants. + #[error("Gateway task transition rejected: {0}")] + Aggregate(#[from] AggregateError), + /// Stored data violates the versioned Task contract. + #[error("corrupt Gateway task storage: {message}")] + Corrupt { + /// Bounded decode or invariant detail. + message: String, + }, + /// SQLite rejected an operation. + #[error("Gateway database operation failed: {0}")] + Sqlite(#[from] rusqlite::Error), + /// A contract value could not be serialized or decoded. + #[error("Gateway task contract serialization failed: {0}")] + Serialization(#[from] serde_json::Error), +} + +impl StoreError { + /// Returns whether a caller can safely retry after refreshing state. + pub fn recoverable(&self) -> bool { + match self { + Self::RevisionConflict { .. } | Self::TaskNotFound => true, + Self::Sqlite(rusqlite::Error::SqliteFailure(code, _)) => matches!( + code.code, + rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked + ), + _ => false, + } + } +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/storage/schema.rs b/src/cosh-ng/crates/cosh-gateway/src/storage/schema.rs new file mode 100644 index 0000000000..61b4c63390 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/storage/schema.rs @@ -0,0 +1,219 @@ +//! Checksummed SQLite schema migrations for Gateway Task storage. + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use super::StoreError; + +pub(super) const CURRENT_SCHEMA_VERSION: u32 = 1; + +struct Migration { + version: u32, + checksum: &'static str, + sql: &'static str, +} + +const MIGRATIONS: &[Migration] = &[Migration { + version: 1, + checksum: "cosh-gateway-task-schema-v1-20260813-causation-nullable", + sql: r#" +CREATE TABLE tasks ( + task_id TEXT PRIMARY KEY NOT NULL, + owner_actor_id TEXT NOT NULL, + target_ref TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision >= 0), + state TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), + updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= created_at_ms) +) STRICT; + +CREATE TABLE task_events ( + event_id TEXT PRIMARY KEY NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE RESTRICT, + revision INTEGER NOT NULL CHECK (revision > 0), + event_type TEXT NOT NULL, + schema_version INTEGER NOT NULL CHECK (schema_version > 0), + payload_json TEXT NOT NULL, + occurred_at_ms INTEGER NOT NULL CHECK (occurred_at_ms >= 0), + causation_id TEXT, + correlation_id TEXT, + UNIQUE(task_id, revision) +) STRICT; + +CREATE TABLE command_receipts ( + actor_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + command_digest TEXT NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE RESTRICT, + task_revision INTEGER NOT NULL CHECK (task_revision >= 0), + receipt_json TEXT NOT NULL, + committed_at_ms INTEGER NOT NULL CHECK (committed_at_ms >= 0), + PRIMARY KEY(actor_id, idempotency_key) +) STRICT; + +CREATE TABLE outbox ( + delivery_id TEXT PRIMARY KEY NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(task_id) ON DELETE RESTRICT, + event_id TEXT NOT NULL REFERENCES task_events(event_id) ON DELETE RESTRICT, + delivery_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'leased', 'delivered', 'dead_letter')), + attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0), + next_attempt_at_ms INTEGER NOT NULL CHECK (next_attempt_at_ms >= 0), + lease_owner TEXT, + lease_expires_at_ms INTEGER, + created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0), + delivered_at_ms INTEGER, + CHECK ((state = 'leased') = (lease_owner IS NOT NULL)), + CHECK ((state = 'leased') = (lease_expires_at_ms IS NOT NULL)) +) STRICT; + +CREATE INDEX task_events_task_revision + ON task_events(task_id, revision); +CREATE INDEX outbox_ready + ON outbox(state, next_attempt_at_ms, created_at_ms); +"#, +}]; + +pub(super) fn migrate(connection: &mut Connection) -> Result<(), StoreError> { + connection.execute_batch( + "BEGIN IMMEDIATE; + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY NOT NULL CHECK (version > 0), + checksum TEXT NOT NULL, + applied_at_ms INTEGER NOT NULL CHECK (applied_at_ms >= 0) + ) STRICT; + COMMIT;", + )?; + + let found = connection.query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_migrations", + [], + |row| row.get::<_, u32>(0), + )?; + if found > CURRENT_SCHEMA_VERSION { + return Err(StoreError::NewerSchema { + found, + supported: CURRENT_SCHEMA_VERSION, + }); + } + + for migration in MIGRATIONS { + let existing = connection + .query_row( + "SELECT checksum FROM schema_migrations WHERE version = ?1", + params![migration.version], + |row| row.get::<_, String>(0), + ) + .optional()?; + match existing { + Some(checksum) if checksum == migration.checksum => continue, + Some(_) => { + return Err(StoreError::MigrationChecksum { + version: migration.version, + }); + } + None => apply_migration(connection, migration)?, + } + } + + let integrity: String = connection.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?; + if integrity != "ok" { + return Err(StoreError::Corrupt { + message: format!("SQLite quick_check failed: {integrity}"), + }); + } + Ok(()) +} + +fn apply_migration(connection: &mut Connection, migration: &Migration) -> Result<(), StoreError> { + let transaction = + connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + transaction.execute_batch(migration.sql)?; + record_migration(&transaction, migration)?; + transaction.commit()?; + Ok(()) +} + +fn record_migration( + transaction: &Transaction<'_>, + migration: &Migration, +) -> Result<(), StoreError> { + transaction.execute( + "INSERT INTO schema_migrations(version, checksum, applied_at_ms) + VALUES (?1, ?2, CAST(unixepoch('subsec') * 1000 AS INTEGER))", + params![migration.version, migration.checksum], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_is_repeatable_and_enables_all_tables() { + let mut connection = Connection::open_in_memory().unwrap(); + connection + .execute_batch("PRAGMA foreign_keys = ON;") + .unwrap(); + migrate(&mut connection).unwrap(); + migrate(&mut connection).unwrap(); + + let tables = connection + .prepare( + "SELECT name FROM sqlite_schema + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + tables, + [ + "command_receipts", + "outbox", + "schema_migrations", + "task_events", + "tasks" + ] + ); + } + + #[test] + fn newer_schema_fails_closed() { + let mut connection = Connection::open_in_memory().unwrap(); + migrate(&mut connection).unwrap(); + connection + .execute( + "INSERT INTO schema_migrations(version, checksum, applied_at_ms) + VALUES (?1, 'future', 0)", + [CURRENT_SCHEMA_VERSION + 1], + ) + .unwrap(); + + assert!(matches!( + migrate(&mut connection), + Err(StoreError::NewerSchema { .. }) + )); + } + + #[test] + fn checksum_mismatch_fails_closed() { + let mut connection = Connection::open_in_memory().unwrap(); + migrate(&mut connection).unwrap(); + connection + .execute( + "UPDATE schema_migrations SET checksum = 'changed' WHERE version = 1", + [], + ) + .unwrap(); + assert!(matches!( + migrate(&mut connection), + Err(StoreError::MigrationChecksum { version: 1 }) + )); + } +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/storage/sqlite.rs b/src/cosh-ng/crates/cosh-gateway/src/storage/sqlite.rs new file mode 100644 index 0000000000..14d2976440 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/storage/sqlite.rs @@ -0,0 +1,333 @@ +//! SQLite connection policy and private-path validation. + +use std::fs::{self, OpenOptions}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use rusqlite::Connection; + +use super::{schema, StoreError}; + +/// Single-writer SQLite Task store configured for local durable operation. +pub struct SqliteTaskStore { + connection: Connection, + path: Option, +} + +impl SqliteTaskStore { + /// Opens or creates a private local database and applies checked migrations. + /// + /// # Errors + /// + /// Returns a fail-closed error for unsafe paths, unsupported migrations, + /// corrupt storage, or SQLite configuration failures. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + prepare_private_path(path)?; + validate_companion_files(path)?; + let mut connection = Connection::open(path)?; + configure(&connection)?; + schema::migrate(&mut connection)?; + validate_companion_files(path)?; + Ok(Self { + connection, + path: Some(path.to_path_buf()), + }) + } + + /// Opens an isolated in-memory store for deterministic unit tests. + /// + /// # Errors + /// + /// Returns migration or SQLite configuration failures. + #[cfg(test)] + pub(crate) fn open_in_memory() -> Result { + let mut connection = Connection::open_in_memory()?; + configure_in_memory(&connection)?; + schema::migrate(&mut connection)?; + Ok(Self { + connection, + path: None, + }) + } + + pub(super) fn connection(&self) -> &Connection { + &self.connection + } + + pub(super) fn connection_mut(&mut self) -> &mut Connection { + &mut self.connection + } + + /// Returns the durable path, or `None` for an in-memory test store. + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } +} + +fn configure(connection: &Connection) -> Result<(), StoreError> { + connection.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA busy_timeout = 5000; + PRAGMA trusted_schema = OFF;", + )?; + Ok(()) +} + +#[cfg(test)] +fn configure_in_memory(connection: &Connection) -> Result<(), StoreError> { + connection.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA synchronous = FULL; + PRAGMA busy_timeout = 5000; + PRAGMA trusted_schema = OFF;", + )?; + Ok(()) +} + +fn prepare_private_path(path: &Path) -> Result<(), StoreError> { + if !path.is_absolute() { + return Err(StoreError::UnsafePath { + path: path.to_path_buf(), + message: "database path must be absolute".to_string(), + }); + } + let parent = path.parent().ok_or_else(|| StoreError::UnsafePath { + path: path.to_path_buf(), + message: "database path has no parent directory".to_string(), + })?; + create_private_path_components(parent)?; + validate_private_permissions(parent, true)?; + + match fs::symlink_metadata(path) { + Ok(_) => { + reject_symlink_or_wrong_type(path, false)?; + validate_private_permissions(path, false)?; + } + Err(error) if error.kind() == ErrorKind::NotFound => create_private_file(path)?, + Err(error) => { + return Err(StoreError::UnsafePath { + path: path.to_path_buf(), + message: format!("inspect database file: {error}"), + }); + } + } + Ok(()) +} + +fn create_private_path_components(parent: &Path) -> Result<(), StoreError> { + let mut current = PathBuf::new(); + for component in parent.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(_) => reject_symlink_or_wrong_type(¤t, true)?, + Err(error) if error.kind() == ErrorKind::NotFound => { + create_private_directory(¤t)?; + reject_symlink_or_wrong_type(¤t, true)?; + } + Err(error) => { + return Err(StoreError::UnsafePath { + path: current, + message: format!("inspect state path component: {error}"), + }); + } + } + } + Ok(()) +} + +fn create_private_directory(path: &Path) -> Result<(), StoreError> { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder + .create(path) + .map_err(|error| StoreError::UnsafePath { + path: path.to_path_buf(), + message: format!("create private state directory: {error}"), + }) +} + +fn create_private_file(path: &Path) -> Result<(), StoreError> { + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map(|_| ()) + .map_err(|error| StoreError::UnsafePath { + path: path.to_path_buf(), + message: format!("create private database file: {error}"), + }) +} + +fn validate_companion_files(path: &Path) -> Result<(), StoreError> { + for suffix in ["-wal", "-shm"] { + let companion = PathBuf::from(format!("{}{}", path.display(), suffix)); + match fs::symlink_metadata(&companion) { + Ok(_) => { + reject_symlink_or_wrong_type(&companion, false)?; + validate_private_permissions(&companion, false)?; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(StoreError::UnsafePath { + path: companion, + message: format!("inspect SQLite companion file: {error}"), + }); + } + } + } + Ok(()) +} + +fn reject_symlink_or_wrong_type(path: &Path, directory: bool) -> Result<(), StoreError> { + let metadata = fs::symlink_metadata(path).map_err(|error| StoreError::UnsafePath { + path: path.to_path_buf(), + message: format!("inspect path: {error}"), + })?; + let file_type = metadata.file_type(); + if file_type.is_symlink() + || (directory && !file_type.is_dir()) + || (!directory && !file_type.is_file()) + { + return Err(StoreError::UnsafePath { + path: path.to_path_buf(), + message: if directory { + "expected a real directory, not a symlink or special file" + } else { + "expected a regular file, not a symlink or special file" + } + .to_string(), + }); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_private_permissions(path: &Path, directory: bool) -> Result<(), StoreError> { + use std::os::unix::fs::PermissionsExt; + + let mode = fs::symlink_metadata(path) + .map_err(|error| StoreError::UnsafePath { + path: path.to_path_buf(), + message: format!("inspect private permissions: {error}"), + })? + .permissions() + .mode(); + if mode & 0o077 != 0 { + return Err(StoreError::UnsafePath { + path: path.to_path_buf(), + message: if directory { + "state directory grants group or other permissions" + } else { + "database file grants group or other permissions" + } + .to_string(), + }); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_private_permissions(_path: &Path, _directory: bool) -> Result<(), StoreError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_database_path_is_rejected() { + assert!(matches!( + SqliteTaskStore::open("relative/state.db"), + Err(StoreError::UnsafePath { .. }) + )); + } + + #[test] + fn durable_store_uses_wal_full_and_foreign_keys() { + let directory = tempfile::tempdir().unwrap(); + let store = SqliteTaskStore::open(directory.path().join("gateway/state.db")).unwrap(); + let journal: String = store + .connection() + .query_row("PRAGMA journal_mode", [], |row| row.get(0)) + .unwrap(); + let synchronous: u32 = store + .connection() + .query_row("PRAGMA synchronous", [], |row| row.get(0)) + .unwrap(); + let foreign_keys: u32 = store + .connection() + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(journal, "wal"); + assert_eq!(synchronous, 2); + assert_eq!(foreign_keys, 1); + } + + #[cfg(unix)] + #[test] + fn symlink_database_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let actual = directory.path().join("actual.db"); + fs::write(&actual, []).unwrap(); + let link = directory.path().join("state.db"); + symlink(&actual, &link).unwrap(); + assert!(matches!( + SqliteTaskStore::open(&link), + Err(StoreError::UnsafePath { .. }) + )); + } + + #[cfg(unix)] + #[test] + fn insecure_existing_parent_is_rejected_without_chmod() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("gateway"); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(matches!( + SqliteTaskStore::open(parent.join("state.db")), + Err(StoreError::UnsafePath { .. }) + )); + assert_eq!( + fs::symlink_metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert!(!parent.join("state.db").exists()); + } + + #[cfg(unix)] + #[test] + fn intermediate_symlink_is_rejected() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let actual = directory.path().join("actual"); + fs::create_dir(&actual).unwrap(); + let link = directory.path().join("linked"); + symlink(&actual, &link).unwrap(); + + assert!(matches!( + SqliteTaskStore::open(link.join("gateway/state.db")), + Err(StoreError::UnsafePath { .. }) + )); + assert!(!actual.join("gateway/state.db").exists()); + } +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/storage/task_store.rs b/src/cosh-ng/crates/cosh-gateway/src/storage/task_store.rs new file mode 100644 index 0000000000..e323de19c1 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/storage/task_store.rs @@ -0,0 +1,472 @@ +//! Atomic Task event, projection, receipt, and Outbox persistence. + +use std::collections::BTreeSet; + +use cosh_gateway_contracts::common::{BoundedName, Digest, IdempotencyKey}; +use cosh_gateway_contracts::ids::{ActorId, DeliveryId, MessageId, TaskId}; +use cosh_gateway_contracts::task::{TaskEventEnvelope, TaskState}; +use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior}; +use serde::{Deserialize, Serialize}; + +use crate::task::TaskAggregate; + +use super::{SqliteTaskStore, StoreError}; + +/// One durable delivery intent created by a Task event transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutboxIntent { + /// Stable identity used to deduplicate downstream delivery. + pub delivery_id: DeliveryId, + /// Event in the same commit that caused this delivery. + pub event_id: MessageId, + /// Stable bounded delivery route. + pub delivery_kind: BoundedName, + /// Versioned delivery payload. + pub payload: serde_json::Value, + /// Earliest delivery attempt time in Unix milliseconds. + pub next_attempt_at_ms: u64, +} + +/// Complete unit of work admitted by the single Task writer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskCommit { + /// Authenticated actor that owns the replay namespace. + pub actor_id: ActorId, + /// Caller-scoped command replay key. + pub idempotency_key: IdempotencyKey, + /// Canonical digest of the admitted command. + pub command_digest: Digest, + /// Optional optimistic revision precondition. + pub expected_revision: Option, + /// Consecutive Task events produced by the command. + pub events: Vec, + /// Delivery intents caused by events in this commit. + pub outbox: Vec, + /// Durable commit timestamp in Unix milliseconds. + pub committed_at_ms: u64, +} + +/// Stable response persisted for exact idempotent replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommitReceipt { + /// Task changed by the command. + pub task_id: TaskId, + /// Latest Task revision after the command. + pub revision: u64, + /// Task event identities committed by the command. + pub event_ids: Vec, + /// Outbox identities committed by the command. + pub delivery_ids: Vec, +} + +/// Result of admitting a command at the durable writer boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommitOutcome { + /// The command produced a new atomic commit. + Applied(CommitReceipt), + /// The same actor, key, and digest returned its durable receipt. + Replayed(CommitReceipt), +} + +impl SqliteTaskStore { + /// Atomically persists an already-authenticated and authorized coordinator + /// decision. This storage boundary does not replace caller-side ingress + /// authentication or authorization policy. + /// + /// Idempotency replay is checked before the optimistic revision, so a + /// retried command returns its original receipt after the Task advances. + /// + /// # Errors + /// + /// Returns a conflict for key or revision reuse, a reducer error for an + /// illegal transition, or a storage error. No partial rows are committed. + pub fn commit_task(&mut self, commit: &TaskCommit) -> Result { + let (task_id, event_ids) = validate_commit_shape(commit)?; + let transaction = self + .connection_mut() + .transaction_with_behavior(TransactionBehavior::Immediate)?; + + if let Some(outcome) = replay_receipt(&transaction, commit)? { + let task_id = match &outcome { + CommitOutcome::Replayed(receipt) | CommitOutcome::Applied(receipt) => { + &receipt.task_id + } + }; + load_verified_projection(&transaction, task_id)? + .ok_or_else(|| corrupt("idempotency receipt references a missing Task"))?; + transaction.commit()?; + return Ok(outcome); + } + + let current = load_verified_projection(&transaction, task_id)?; + if current + .as_ref() + .is_some_and(|aggregate| aggregate.owner_actor_id() != &commit.actor_id) + { + return Err(invalid("commit actor does not own the existing Task")); + } + let actual_revision = current.as_ref().map_or(0, TaskAggregate::revision); + if let Some(expected) = commit.expected_revision { + if expected != actual_revision { + return Err(StoreError::RevisionConflict { + expected, + actual: actual_revision, + }); + } + } + + let aggregate = reduce_commit(current, &commit.events)?; + if aggregate.owner_actor_id() != &commit.actor_id { + return Err(invalid("commit actor does not own the created Task")); + } + persist_projection( + &transaction, + &aggregate, + actual_revision, + commit.committed_at_ms, + )?; + append_events(&transaction, &commit.events)?; + append_outbox(&transaction, task_id, commit)?; + + let receipt = CommitReceipt { + task_id: task_id.clone(), + revision: aggregate.revision(), + event_ids, + delivery_ids: commit + .outbox + .iter() + .map(|intent| intent.delivery_id.clone()) + .collect(), + }; + insert_receipt(&transaction, commit, &receipt)?; + transaction.commit()?; + Ok(CommitOutcome::Applied(receipt)) + } + + /// Loads the latest durable Task projection. + /// + /// # Errors + /// + /// Returns `TaskNotFound` or rejects a corrupt or divergent projection. + pub fn load_task(&self, task_id: &TaskId) -> Result { + load_verified_projection(self.connection(), task_id)?.ok_or(StoreError::TaskNotFound) + } + + /// Rebuilds a Task from its immutable events and verifies the stored + /// projection matches the deterministic reducer result. + /// + /// # Errors + /// + /// Returns `TaskNotFound` or rejects corrupt, incomplete, or divergent data. + pub fn recover_task(&self, task_id: &TaskId) -> Result { + self.load_task(task_id) + } +} + +fn validate_commit_shape(commit: &TaskCommit) -> Result<(&TaskId, Vec), StoreError> { + let first = commit + .events + .first() + .ok_or_else(|| invalid("event batch is empty"))?; + if commit + .events + .iter() + .any(|event| event.task_id != first.task_id) + { + return Err(invalid("event batch contains multiple Task identities")); + } + if commit + .events + .iter() + .any(|event| event.header.correlation.actor_id.as_ref() != Some(&commit.actor_id)) + { + return Err(invalid( + "every event actor correlation must match the admitted commit actor", + )); + } + let event_ids = commit + .events + .iter() + .map(|event| event.header.message_id.clone()) + .collect::>(); + let unique_event_ids = event_ids.iter().collect::>(); + if unique_event_ids.len() != event_ids.len() { + return Err(invalid("event batch reuses a message identity")); + } + if commit.outbox.iter().any(|intent| { + !event_ids + .iter() + .any(|event_id| event_id == &intent.event_id) + }) { + return Err(invalid( + "Outbox intent references an event outside the commit", + )); + } + Ok((&first.task_id, event_ids)) +} + +fn replay_receipt( + transaction: &Transaction<'_>, + commit: &TaskCommit, +) -> Result, StoreError> { + let existing = transaction + .query_row( + "SELECT command_digest, receipt_json FROM command_receipts + WHERE actor_id = ?1 AND idempotency_key = ?2", + params![commit.actor_id.as_str(), commit.idempotency_key.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let Some((digest, receipt_json)) = existing else { + return Ok(None); + }; + if digest != commit.command_digest.as_str() { + return Err(StoreError::IdempotencyConflict); + } + let receipt = serde_json::from_str::(&receipt_json)?; + Ok(Some(CommitOutcome::Replayed(receipt))) +} + +fn load_snapshot( + connection: &rusqlite::Connection, + task_id: &TaskId, +) -> Result, StoreError> { + let stored = connection + .query_row( + "SELECT revision, snapshot_json FROM tasks WHERE task_id = ?1", + params![task_id.as_str()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let Some((revision, snapshot_json)) = stored else { + return Ok(None); + }; + let revision = u64::try_from(revision).map_err(|_| corrupt("negative Task revision"))?; + let aggregate = serde_json::from_str::(&snapshot_json) + .map_err(|error| corrupt(&format!("Task snapshot cannot be decoded: {error}")))?; + if aggregate.task_id() != task_id || aggregate.revision() != revision { + return Err(corrupt( + "Task snapshot identity or revision does not match its row", + )); + } + Ok(Some(aggregate)) +} + +fn load_verified_projection( + connection: &rusqlite::Connection, + task_id: &TaskId, +) -> Result, StoreError> { + let snapshot = load_snapshot(connection, task_id)?; + let events = load_events(connection, task_id)?; + match (snapshot, events.is_empty()) { + (None, true) => Ok(None), + (None, false) => Err(corrupt("Task event stream has no projection")), + (Some(_), true) => Err(corrupt("Task projection has no event stream")), + (Some(snapshot), false) => { + let recovered = TaskAggregate::replay(&events)?; + if recovered != snapshot { + return Err(corrupt("stored projection diverges from event replay")); + } + Ok(Some(recovered)) + } + } +} + +fn load_events( + connection: &rusqlite::Connection, + task_id: &TaskId, +) -> Result, StoreError> { + let mut statement = connection.prepare( + "SELECT payload_json FROM task_events + WHERE task_id = ?1 ORDER BY revision ASC", + )?; + let payloads = statement + .query_map(params![task_id.as_str()], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + payloads + .into_iter() + .map(|payload| { + serde_json::from_str::(&payload) + .map_err(|error| corrupt(&format!("Task event cannot be decoded: {error}"))) + }) + .collect() +} + +fn reduce_commit( + current: Option, + events: &[TaskEventEnvelope], +) -> Result { + match current { + Some(mut aggregate) => { + for event in events { + aggregate.apply(event)?; + } + Ok(aggregate) + } + None => Ok(TaskAggregate::replay(events)?), + } +} + +fn persist_projection( + transaction: &Transaction<'_>, + aggregate: &TaskAggregate, + previous_revision: u64, + committed_at_ms: u64, +) -> Result<(), StoreError> { + let revision = sqlite_integer(aggregate.revision(), "Task revision")?; + let previous_revision = sqlite_integer(previous_revision, "previous Task revision")?; + let committed_at_ms = sqlite_integer(committed_at_ms, "commit timestamp")?; + let snapshot_json = serde_json::to_string(aggregate)?; + let target_ref = serde_json::to_string(aggregate.target())?; + let state = task_state_name(aggregate.state())?; + if previous_revision == 0 { + transaction.execute( + "INSERT INTO tasks( + task_id, owner_actor_id, target_ref, revision, state, + snapshot_json, created_at_ms, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + aggregate.task_id().as_str(), + aggregate.owner_actor_id().as_str(), + target_ref, + revision, + state, + snapshot_json, + committed_at_ms, + ], + )?; + } else { + let changed = transaction.execute( + "UPDATE tasks SET revision = ?2, state = ?3, snapshot_json = ?4, + updated_at_ms = ?5 + WHERE task_id = ?1 AND revision = ?6", + params![ + aggregate.task_id().as_str(), + revision, + state, + snapshot_json, + committed_at_ms, + previous_revision, + ], + )?; + if changed != 1 { + return Err(corrupt("Task projection compare-and-swap changed no row")); + } + } + Ok(()) +} + +fn append_events( + transaction: &Transaction<'_>, + events: &[TaskEventEnvelope], +) -> Result<(), StoreError> { + let mut statement = transaction.prepare( + "INSERT INTO task_events( + event_id, task_id, revision, event_type, schema_version, + payload_json, occurred_at_ms, causation_id, correlation_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + )?; + for event in events { + let revision = sqlite_integer(event.revision, "event revision")?; + let occurred_at_ms = sqlite_integer(event.header.occurred_at_ms, "event timestamp")?; + let payload_json = serde_json::to_string(event)?; + let event_type = serde_json::to_value(event.event.kind())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| corrupt("Task event kind is not a string"))?; + statement.execute(params![ + event.header.message_id.as_str(), + event.task_id.as_str(), + revision, + event_type, + i64::from(event.header.schema_version), + payload_json, + occurred_at_ms, + event + .header + .correlation + .causation_message_id + .as_ref() + .map(MessageId::as_str), + Option::<&str>::None, + ])?; + } + Ok(()) +} + +fn append_outbox( + transaction: &Transaction<'_>, + task_id: &TaskId, + commit: &TaskCommit, +) -> Result<(), StoreError> { + let created_at_ms = sqlite_integer(commit.committed_at_ms, "Outbox timestamp")?; + let mut statement = transaction.prepare( + "INSERT INTO outbox( + delivery_id, task_id, event_id, delivery_kind, payload_json, + state, next_attempt_at_ms, created_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)", + )?; + for intent in &commit.outbox { + let next_attempt_at_ms = + sqlite_integer(intent.next_attempt_at_ms, "Outbox next-attempt timestamp")?; + statement.execute(params![ + intent.delivery_id.as_str(), + task_id.as_str(), + intent.event_id.as_str(), + intent.delivery_kind.as_str(), + serde_json::to_string(&intent.payload)?, + next_attempt_at_ms, + created_at_ms, + ])?; + } + Ok(()) +} + +fn insert_receipt( + transaction: &Transaction<'_>, + commit: &TaskCommit, + receipt: &CommitReceipt, +) -> Result<(), StoreError> { + transaction.execute( + "INSERT INTO command_receipts( + actor_id, idempotency_key, command_digest, task_id, + task_revision, receipt_json, committed_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + commit.actor_id.as_str(), + commit.idempotency_key.as_str(), + commit.command_digest.as_str(), + receipt.task_id.as_str(), + sqlite_integer(receipt.revision, "receipt Task revision")?, + serde_json::to_string(receipt)?, + sqlite_integer(commit.committed_at_ms, "receipt timestamp")?, + ], + )?; + Ok(()) +} + +fn task_state_name(state: TaskState) -> Result { + serde_json::to_value(state)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| corrupt("Task state is not a string")) +} + +fn sqlite_integer(value: u64, field: &str) -> Result { + i64::try_from(value).map_err(|_| invalid(&format!("{field} exceeds SQLite INTEGER range"))) +} + +fn invalid(message: &str) -> StoreError { + StoreError::InvalidCommit { + message: message.to_string(), + } +} + +fn corrupt(message: &str) -> StoreError { + StoreError::Corrupt { + message: message.to_string(), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/cosh-ng/crates/cosh-gateway/src/storage/task_store/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/storage/task_store/tests.rs new file mode 100644 index 0000000000..91d25ef6ca --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/storage/task_store/tests.rs @@ -0,0 +1,407 @@ +use std::path::Path; + +use cosh_gateway_contracts::common::{ + BoundedOpaque, ContractHeader, ContractSchema, Correlation, RuntimeSelector, TargetRef, +}; +use cosh_gateway_contracts::ids::{InstallationId, RunId}; +use cosh_gateway_contracts::task::TaskEvent; + +use super::*; + +fn envelope( + task_id: &TaskId, + actor_id: &ActorId, + revision: u64, + event: TaskEvent, +) -> TaskEventEnvelope { + let mut correlation = Correlation::new(InstallationId::new()); + correlation.actor_id = Some(actor_id.clone()); + correlation.task_id = Some(task_id.clone()); + TaskEventEnvelope { + header: ContractHeader::new( + ContractSchema::TaskEvent, + MessageId::new(), + revision, + correlation, + ), + task_id: task_id.clone(), + revision, + event, + } +} + +fn submitted(task_id: &TaskId, actor_id: &ActorId) -> TaskEventEnvelope { + envelope( + task_id, + actor_id, + 1, + TaskEvent::TaskSubmitted { + intent_digest: Digest::parse("a".repeat(64)).unwrap(), + target: TargetRef { + kind: BoundedName::new("local").unwrap(), + authority: BoundedName::new("test").unwrap(), + identifier: BoundedOpaque::new("target").unwrap(), + }, + }, + ) +} + +fn task_commit( + task_id: &TaskId, + actor_id: &ActorId, + key: &str, + digest: char, + events: Vec, + outbox: Vec, +) -> TaskCommit { + let _ = task_id; + TaskCommit { + actor_id: actor_id.clone(), + idempotency_key: IdempotencyKey::new(key).unwrap(), + command_digest: Digest::parse(digest.to_string().repeat(64)).unwrap(), + expected_revision: Some(events.first().map_or(0, |event| event.revision - 1)), + events, + outbox, + committed_at_ms: 100, + } +} + +fn outbox(event: &TaskEventEnvelope, delivery_id: DeliveryId) -> OutboxIntent { + OutboxIntent { + delivery_id, + event_id: event.header.message_id.clone(), + delivery_kind: BoundedName::new("task_event").unwrap(), + payload: serde_json::json!({"event_id": event.header.message_id}), + next_attempt_at_ms: 100, + } +} + +fn table_count(store: &SqliteTaskStore, table: &str) -> i64 { + let query = format!("SELECT COUNT(*) FROM {table}"); + store + .connection() + .query_row(&query, [], |row| row.get(0)) + .unwrap() +} + +#[test] +fn commits_projection_event_receipt_and_outbox_atomically() { + let mut store = SqliteTaskStore::open_in_memory().unwrap(); + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let event = submitted(&task_id, &actor_id); + let delivery_id = DeliveryId::new(); + let commit = task_commit( + &task_id, + &actor_id, + "create", + 'a', + vec![event.clone()], + vec![outbox(&event, delivery_id.clone())], + ); + + let outcome = store.commit_task(&commit).unwrap(); + let CommitOutcome::Applied(receipt) = outcome else { + panic!("first commit must be applied") + }; + assert_eq!(receipt.revision, 1); + assert_eq!(receipt.delivery_ids, [delivery_id]); + assert_eq!(store.load_task(&task_id).unwrap().revision(), 1); + assert_eq!(table_count(&store, "task_events"), 1); + assert_eq!(table_count(&store, "command_receipts"), 1); + assert_eq!(table_count(&store, "outbox"), 1); +} + +#[test] +fn idempotency_replays_same_digest_and_rejects_conflict() { + let mut store = SqliteTaskStore::open_in_memory().unwrap(); + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let event = submitted(&task_id, &actor_id); + let mut commit = task_commit( + &task_id, + &actor_id, + "same-key", + 'b', + vec![event], + Vec::new(), + ); + + let applied = store.commit_task(&commit).unwrap(); + assert!(matches!(applied, CommitOutcome::Applied(_))); + commit.expected_revision = Some(99); + let replayed = store.commit_task(&commit).unwrap(); + assert!(matches!(replayed, CommitOutcome::Replayed(_))); + commit.command_digest = Digest::parse("c".repeat(64)).unwrap(); + assert!(matches!( + store.commit_task(&commit), + Err(StoreError::IdempotencyConflict) + )); + assert_eq!(table_count(&store, "task_events"), 1); +} + +#[test] +fn revision_conflict_has_no_partial_rows() { + let mut store = SqliteTaskStore::open_in_memory().unwrap(); + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let event = submitted(&task_id, &actor_id); + let mut commit = task_commit( + &task_id, + &actor_id, + "conflict", + 'd', + vec![event], + Vec::new(), + ); + commit.expected_revision = Some(1); + + assert!(matches!( + store.commit_task(&commit), + Err(StoreError::RevisionConflict { + expected: 1, + actual: 0 + }) + )); + assert_eq!(table_count(&store, "tasks"), 0); + assert_eq!(table_count(&store, "task_events"), 0); + assert_eq!(table_count(&store, "command_receipts"), 0); +} + +#[test] +fn actor_substitution_cannot_append_or_create_partial_rows() { + let mut store = SqliteTaskStore::open_in_memory().unwrap(); + let task_id = TaskId::new(); + let owner = ActorId::new(); + let attacker = ActorId::new(); + let event = submitted(&task_id, &owner); + let substituted_create = task_commit( + &task_id, + &attacker, + "substitute-create", + '3', + vec![event], + Vec::new(), + ); + assert!(matches!( + store.commit_task(&substituted_create), + Err(StoreError::InvalidCommit { .. }) + )); + assert_eq!(table_count(&store, "tasks"), 0); + + let create_event = submitted(&task_id, &owner); + store + .commit_task(&task_commit( + &task_id, + &owner, + "owner-create", + '4', + vec![create_event], + Vec::new(), + )) + .unwrap(); + let queued = envelope( + &task_id, + &attacker, + 2, + TaskEvent::TaskQueued { + run_id: RunId::new(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ); + assert!(matches!( + store.commit_task(&task_commit( + &task_id, + &attacker, + "substitute-append", + '5', + vec![queued], + Vec::new(), + )), + Err(StoreError::InvalidCommit { .. }) + )); + assert_eq!(store.load_task(&task_id).unwrap().revision(), 1); + assert_eq!(table_count(&store, "task_events"), 1); + assert_eq!(table_count(&store, "command_receipts"), 1); +} + +#[test] +fn failed_outbox_insert_rolls_back_task_append() { + let mut store = SqliteTaskStore::open_in_memory().unwrap(); + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let initial = submitted(&task_id, &actor_id); + let duplicate_delivery = DeliveryId::new(); + let create = task_commit( + &task_id, + &actor_id, + "create", + 'e', + vec![initial.clone()], + vec![outbox(&initial, duplicate_delivery.clone())], + ); + store.commit_task(&create).unwrap(); + + let queued = envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: RunId::new(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ); + let append = task_commit( + &task_id, + &actor_id, + "queue", + 'f', + vec![queued.clone()], + vec![outbox(&queued, duplicate_delivery)], + ); + assert!(matches!( + store.commit_task(&append), + Err(StoreError::Sqlite(_)) + )); + assert_eq!(store.load_task(&task_id).unwrap().revision(), 1); + assert_eq!(table_count(&store, "task_events"), 1); + assert_eq!(table_count(&store, "command_receipts"), 1); + assert_eq!(table_count(&store, "outbox"), 1); +} + +#[test] +fn recovers_projection_after_durable_reopen() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("gateway/state.db"); + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + { + let mut store = SqliteTaskStore::open(&path).unwrap(); + let event = submitted(&task_id, &actor_id); + let event_id = event.header.message_id.clone(); + store + .commit_task(&task_commit( + &task_id, + &actor_id, + "recover", + '1', + vec![event], + Vec::new(), + )) + .unwrap(); + let mut queued = envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: RunId::new(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ); + queued.header.correlation.causation_message_id = Some(event_id.clone()); + store + .commit_task(&task_commit( + &task_id, + &actor_id, + "queue-after-recover", + '2', + vec![queued], + Vec::new(), + )) + .unwrap(); + let causation: Option = store + .connection() + .query_row( + "SELECT causation_id FROM task_events + WHERE task_id = ?1 AND revision = 2", + params![task_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(causation.as_deref(), Some(event_id.as_str())); + } + + let store = SqliteTaskStore::open(Path::new(&path)).unwrap(); + let recovered = store.recover_task(&task_id).unwrap(); + assert_eq!(recovered.task_id(), &task_id); + assert_eq!(recovered.revision(), 2); + assert_eq!(recovered.state(), TaskState::Queued); +} + +#[test] +fn normal_load_and_commit_reject_divergent_snapshot() { + let mut store = SqliteTaskStore::open_in_memory().unwrap(); + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let create = task_commit( + &task_id, + &actor_id, + "verified-create", + '6', + vec![submitted(&task_id, &actor_id)], + Vec::new(), + ); + store.commit_task(&create).unwrap(); + + let snapshot_json: String = store + .connection() + .query_row( + "SELECT snapshot_json FROM tasks WHERE task_id = ?1", + params![task_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + let mut snapshot: serde_json::Value = serde_json::from_str(&snapshot_json).unwrap(); + snapshot["state"] = serde_json::Value::String("queued".to_string()); + store + .connection() + .execute( + "UPDATE tasks SET snapshot_json = ?2 WHERE task_id = ?1", + params![task_id.as_str(), serde_json::to_string(&snapshot).unwrap()], + ) + .unwrap(); + + assert!(matches!( + store.load_task(&task_id), + Err(StoreError::Corrupt { .. }) + )); + assert!(matches!( + store.commit_task(&create), + Err(StoreError::Corrupt { .. }) + )); + + let queued = envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: RunId::new(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ); + assert!(matches!( + store.commit_task(&task_commit( + &task_id, + &actor_id, + "verified-append", + '7', + vec![queued], + Vec::new(), + )), + Err(StoreError::Corrupt { .. }) + )); + assert_eq!(table_count(&store, "task_events"), 1); + assert_eq!(table_count(&store, "command_receipts"), 1); +} diff --git a/src/cosh-ng/crates/cosh-gateway/src/task.rs b/src/cosh-ng/crates/cosh-gateway/src/task.rs new file mode 100644 index 0000000000..61919a5585 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/task.rs @@ -0,0 +1,5 @@ +//! Task aggregate reduction and command coordination invariants. + +mod aggregate; + +pub use aggregate::{AggregateError, TaskAggregate}; diff --git a/src/cosh-ng/crates/cosh-gateway/src/task/aggregate.rs b/src/cosh-ng/crates/cosh-gateway/src/task/aggregate.rs new file mode 100644 index 0000000000..0d22f41346 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/task/aggregate.rs @@ -0,0 +1,472 @@ +//! Pure reducer for versioned Task lifecycle events. + +use std::collections::BTreeSet; + +use cosh_gateway_contracts::capability::ApprovalDecision; +use cosh_gateway_contracts::common::{ContractSchema, TargetRef, CONTRACT_SCHEMA_VERSION}; +use cosh_gateway_contracts::ids::{ActorId, ApprovalId, ExecutionId, RunId, TaskId}; +use cosh_gateway_contracts::task::{TaskEvent, TaskEventEnvelope, TaskState}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Task projection reduced exclusively from immutable Task events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskAggregate { + task_id: TaskId, + owner_actor_id: ActorId, + target: TargetRef, + revision: u64, + state: TaskState, + active_run_id: Option, + run_outcome: RunOutcome, + cancellation_requested: bool, + pending_approvals: BTreeSet, + planned_executions: BTreeSet, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum RunOutcome { + None, + Active, + Suspended, + Succeeded, + Failed, + Cancelled, + Uncertain, +} + +/// A Task event violates identity, revision, or lifecycle invariants. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AggregateError { + /// Event schema is not the Task event contract. + #[error("event header does not declare the Task event schema")] + WrongSchema, + /// Event schema version is unsupported even when constructed in memory. + #[error("event schema version must be {expected}, got {actual}")] + WrongSchemaVersion { + /// Version accepted by this reducer. + expected: u16, + /// Version carried by the rejected event. + actual: u16, + }, + /// Event and aggregate Task identities differ. + #[error("event Task identity does not match the aggregate")] + TaskMismatch, + /// Header correlation does not bind the same Task. + #[error("event correlation does not bind the aggregate Task")] + CorrelationMismatch, + /// Event revisions must be consecutive. + #[error("event revision must be {expected}, got {actual}")] + RevisionGap { + /// Next revision required by the aggregate. + expected: u64, + /// Revision carried by the rejected event. + actual: u64, + }, + /// The first event is not a valid Task creation event. + #[error("the first Task event must be task_submitted with an actor correlation")] + InvalidFirstEvent, + /// The event is illegal for the current lifecycle state. + #[error("event {event} is invalid while Task state is {state:?}")] + InvalidTransition { + /// Stable event discriminator. + event: String, + /// State that rejected the event. + state: TaskState, + }, + /// An event references a Run other than the active Run. + #[error("event Run identity does not match the active Run")] + RunMismatch, + /// An approval event references an unknown or already-resolved approval. + #[error("approval is not pending for this Task")] + ApprovalNotPending, + /// An execution result references an execution that was not planned. + #[error("execution is not planned for this Task")] + ExecutionNotPlanned, +} + +impl TaskAggregate { + /// Rebuilds a Task projection from a non-empty ordered event stream. + /// + /// # Errors + /// + /// Returns the first identity, revision, or transition violation. + pub fn replay(events: &[TaskEventEnvelope]) -> Result { + let (first, rest) = events + .split_first() + .ok_or(AggregateError::InvalidFirstEvent)?; + validate_task_header(first)?; + if first.revision != 1 { + return Err(AggregateError::RevisionGap { + expected: 1, + actual: first.revision, + }); + } + let TaskEvent::TaskSubmitted { target, .. } = &first.event else { + return Err(AggregateError::InvalidFirstEvent); + }; + let owner_actor_id = first + .header + .correlation + .actor_id + .clone() + .ok_or(AggregateError::InvalidFirstEvent)?; + let mut aggregate = Self { + task_id: first.task_id.clone(), + owner_actor_id, + target: target.clone(), + revision: 1, + state: TaskState::Submitted, + active_run_id: None, + run_outcome: RunOutcome::None, + cancellation_requested: false, + pending_approvals: BTreeSet::new(), + planned_executions: BTreeSet::new(), + }; + for envelope in rest { + aggregate.apply(envelope)?; + } + Ok(aggregate) + } + + /// Applies one consecutive event after validating the complete transition. + /// + /// # Errors + /// + /// Returns an invariant error without modifying the aggregate. + pub fn apply(&mut self, envelope: &TaskEventEnvelope) -> Result<(), AggregateError> { + validate_task_header(envelope)?; + if envelope.task_id != self.task_id { + return Err(AggregateError::TaskMismatch); + } + if envelope.header.correlation.actor_id.as_ref() != Some(&self.owner_actor_id) { + return Err(AggregateError::CorrelationMismatch); + } + let expected = self.revision.saturating_add(1); + if envelope.revision != expected { + return Err(AggregateError::RevisionGap { + expected, + actual: envelope.revision, + }); + } + + let mut next = self.clone(); + next.reduce(&envelope.event)?; + next.revision = envelope.revision; + *self = next; + Ok(()) + } + + /// Returns the durable Task identity. + #[must_use] + pub fn task_id(&self) -> &TaskId { + &self.task_id + } + + /// Returns the actor that owns the Task. + #[must_use] + pub fn owner_actor_id(&self) -> &ActorId { + &self.owner_actor_id + } + + /// Returns the target reference selected when the Task was admitted. + #[must_use] + pub fn target(&self) -> &TargetRef { + &self.target + } + + /// Returns the latest committed Task revision. + #[must_use] + pub fn revision(&self) -> u64 { + self.revision + } + + /// Returns the current durable lifecycle state. + #[must_use] + pub fn state(&self) -> TaskState { + self.state + } + + /// Returns the current Run identity, when one has been allocated. + #[must_use] + pub fn active_run_id(&self) -> Option<&RunId> { + self.active_run_id.as_ref() + } + + fn reduce(&mut self, event: &TaskEvent) -> Result<(), AggregateError> { + match event { + TaskEvent::TaskSubmitted { .. } => return self.invalid(event), + TaskEvent::TaskQueued { run_id, .. } => { + self.require_state(event, &[TaskState::Submitted])?; + self.state = TaskState::Queued; + self.active_run_id = Some(run_id.clone()); + self.run_outcome = RunOutcome::None; + } + TaskEvent::RunStarted { run_id } => { + self.require_state(event, &[TaskState::Queued])?; + self.require_run(run_id)?; + self.state = TaskState::Running; + self.run_outcome = RunOutcome::Active; + } + TaskEvent::RuntimeBound { run_id, binding } => { + self.require_running_event(event, run_id)?; + if binding.task_id != self.task_id || binding.run_id != *run_id { + return Err(AggregateError::CorrelationMismatch); + } + } + TaskEvent::RuntimeEventRecorded { run_id, .. } => { + self.require_running_event(event, run_id)?; + } + TaskEvent::ApprovalRequested { approval } => { + self.require_running_event(event, &approval.run_id)?; + if approval.task_id != self.task_id + || !self.pending_approvals.insert(approval.approval_id.clone()) + { + return Err(AggregateError::ApprovalNotPending); + } + self.state = TaskState::WaitingApproval; + self.run_outcome = RunOutcome::Suspended; + } + TaskEvent::ApprovalResolved { + approval_id, + decision, + } => { + self.require_state(event, &[TaskState::WaitingApproval])?; + if !self.pending_approvals.remove(approval_id) { + return Err(AggregateError::ApprovalNotPending); + } + if self.pending_approvals.is_empty() { + if matches!(decision, ApprovalDecision::Approve) { + self.state = TaskState::Running; + self.run_outcome = RunOutcome::Active; + } else { + self.state = TaskState::Suspended; + self.run_outcome = RunOutcome::Suspended; + } + } + } + TaskEvent::ExecutionPlanned { execution_id, .. } => { + self.require_active(event)?; + if !self.planned_executions.insert(execution_id.clone()) { + return Err(AggregateError::ExecutionNotPlanned); + } + } + TaskEvent::ExecutionResultRecorded { execution_id, .. } => { + self.require_active(event)?; + if !self.planned_executions.remove(execution_id) { + return Err(AggregateError::ExecutionNotPlanned); + } + } + TaskEvent::ExecutionUncertain { execution_id, .. } => { + self.require_active(event)?; + if !self.planned_executions.remove(execution_id) { + return Err(AggregateError::ExecutionNotPlanned); + } + self.state = TaskState::Suspended; + self.run_outcome = RunOutcome::Uncertain; + } + TaskEvent::CancellationRequested { run_id, .. } => { + self.require_state( + event, + &[ + TaskState::Queued, + TaskState::Running, + TaskState::WaitingApproval, + TaskState::WaitingInput, + TaskState::Suspended, + ], + )?; + self.require_run(run_id)?; + if self.state == TaskState::Running && self.run_outcome != RunOutcome::Active { + return self.invalid(event); + } + if self.cancellation_requested { + return self.invalid(event); + } + self.cancellation_requested = true; + } + TaskEvent::RunCancelled { run_id, .. } => { + self.require_state( + event, + &[ + TaskState::Queued, + TaskState::Running, + TaskState::WaitingApproval, + TaskState::WaitingInput, + TaskState::Suspended, + ], + )?; + self.require_run(run_id)?; + if matches!(self.run_outcome, RunOutcome::Succeeded | RunOutcome::Failed) { + return self.invalid(event); + } + if !self.cancellation_requested + || self.run_outcome == RunOutcome::Uncertain + || !self.planned_executions.is_empty() + { + return self.invalid(event); + } + self.state = TaskState::Suspended; + self.run_outcome = RunOutcome::Cancelled; + self.pending_approvals.clear(); + } + TaskEvent::RunSuspended { run_id, .. } => { + self.require_running_event(event, run_id)?; + if !self.planned_executions.is_empty() { + return self.invalid(event); + } + self.state = TaskState::Suspended; + self.run_outcome = RunOutcome::Suspended; + } + TaskEvent::RunSucceeded { run_id } => { + self.require_running_event(event, run_id)?; + if !self.planned_executions.is_empty() { + return self.invalid(event); + } + self.run_outcome = RunOutcome::Succeeded; + } + TaskEvent::RunFailed { run_id, .. } => { + self.require_state(event, &[TaskState::Running, TaskState::Suspended])?; + self.require_run(run_id)?; + if (self.state == TaskState::Running && self.run_outcome != RunOutcome::Active) + || (self.state == TaskState::Suspended + && self.run_outcome != RunOutcome::Suspended) + || !self.planned_executions.is_empty() + { + return self.invalid(event); + } + self.state = TaskState::Suspended; + self.run_outcome = RunOutcome::Failed; + self.pending_approvals.clear(); + } + TaskEvent::RunRetryQueued { + previous_run_id, + next_run_id, + } => { + self.require_state(event, &[TaskState::Suspended])?; + self.require_run(previous_run_id)?; + if !matches!(self.run_outcome, RunOutcome::Suspended | RunOutcome::Failed) + || self.cancellation_requested + || !self.planned_executions.is_empty() + { + return self.invalid(event); + } + self.state = TaskState::Queued; + self.active_run_id = Some(next_run_id.clone()); + self.run_outcome = RunOutcome::None; + self.pending_approvals.clear(); + } + TaskEvent::TaskSucceeded => { + self.require_state(event, &[TaskState::Running])?; + if self.run_outcome != RunOutcome::Succeeded { + return self.invalid(event); + } + self.state = TaskState::Succeeded; + } + TaskEvent::TaskFailed { .. } => { + self.require_state(event, &[TaskState::Suspended])?; + if self.run_outcome != RunOutcome::Failed { + return self.invalid(event); + } + self.state = TaskState::Failed; + } + TaskEvent::TaskCancelled => { + if self.state == TaskState::Submitted { + self.state = TaskState::Cancelled; + return Ok(()); + } + self.require_state( + event, + &[ + TaskState::Queued, + TaskState::Running, + TaskState::WaitingApproval, + TaskState::WaitingInput, + TaskState::Suspended, + ], + )?; + if !self.cancellation_requested + || (self.active_run_id.is_some() + && self.run_outcome != RunOutcome::Cancelled + && self.state != TaskState::Queued) + { + return self.invalid(event); + } + self.state = TaskState::Cancelled; + } + } + Ok(()) + } + + fn require_running_event( + &self, + event: &TaskEvent, + run_id: &RunId, + ) -> Result<(), AggregateError> { + self.require_active(event)?; + self.require_run(run_id) + } + + fn require_active(&self, event: &TaskEvent) -> Result<(), AggregateError> { + self.require_state(event, &[TaskState::Running])?; + if self.run_outcome == RunOutcome::Active { + Ok(()) + } else { + self.invalid(event) + } + } + + fn require_run(&self, run_id: &RunId) -> Result<(), AggregateError> { + if self.active_run_id.as_ref() == Some(run_id) { + Ok(()) + } else { + Err(AggregateError::RunMismatch) + } + } + + fn require_state( + &self, + event: &TaskEvent, + allowed: &[TaskState], + ) -> Result<(), AggregateError> { + if allowed.contains(&self.state) { + Ok(()) + } else { + self.invalid(event) + } + } + + fn invalid(&self, event: &TaskEvent) -> Result { + Err(AggregateError::InvalidTransition { + event: task_event_kind_name(event), + state: self.state, + }) + } +} + +fn validate_task_header(envelope: &TaskEventEnvelope) -> Result<(), AggregateError> { + if envelope.header.schema != ContractSchema::TaskEvent { + return Err(AggregateError::WrongSchema); + } + if envelope.header.schema_version != CONTRACT_SCHEMA_VERSION { + return Err(AggregateError::WrongSchemaVersion { + expected: CONTRACT_SCHEMA_VERSION, + actual: envelope.header.schema_version, + }); + } + if envelope.header.correlation.task_id.as_ref() != Some(&envelope.task_id) { + return Err(AggregateError::CorrelationMismatch); + } + Ok(()) +} + +fn task_event_kind_name(event: &TaskEvent) -> String { + serde_json::to_value(event.kind()) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| "unknown".to_string()) +} + +#[cfg(test)] +mod tests; diff --git a/src/cosh-ng/crates/cosh-gateway/src/task/aggregate/tests.rs b/src/cosh-ng/crates/cosh-gateway/src/task/aggregate/tests.rs new file mode 100644 index 0000000000..eaa432ef17 --- /dev/null +++ b/src/cosh-ng/crates/cosh-gateway/src/task/aggregate/tests.rs @@ -0,0 +1,563 @@ +use cosh_gateway_contracts::common::{ + BoundedName, BoundedOpaque, BoundedText, ContractHeader, Correlation, Digest, RuntimeSelector, +}; +use cosh_gateway_contracts::error::{ContractError, ErrorCategory}; +use cosh_gateway_contracts::ids::{InstallationId, MessageId, PermitId, RequestId}; +use cosh_gateway_contracts::task::{ + CancelReason, CancellationStage, RuntimeUpdate, SuspensionCode, UncertaintyCode, +}; + +use super::*; + +fn target() -> TargetRef { + TargetRef { + kind: BoundedName::new("local").unwrap(), + authority: BoundedName::new("test").unwrap(), + identifier: BoundedOpaque::new("target").unwrap(), + } +} + +fn envelope( + task_id: &TaskId, + actor_id: &ActorId, + revision: u64, + event: TaskEvent, +) -> TaskEventEnvelope { + let mut correlation = Correlation::new(InstallationId::new()); + correlation.actor_id = Some(actor_id.clone()); + correlation.task_id = Some(task_id.clone()); + TaskEventEnvelope { + header: ContractHeader::new( + ContractSchema::TaskEvent, + MessageId::new(), + revision, + correlation, + ), + task_id: task_id.clone(), + revision, + event, + } +} + +fn submitted(task_id: &TaskId, actor_id: &ActorId) -> TaskEventEnvelope { + envelope( + task_id, + actor_id, + 1, + TaskEvent::TaskSubmitted { + intent_digest: Digest::parse("a".repeat(64)).unwrap(), + target: target(), + }, + ) +} + +fn running(task_id: &TaskId, actor_id: &ActorId, run_id: &RunId) -> TaskAggregate { + TaskAggregate::replay(&[ + submitted(task_id, actor_id), + envelope( + task_id, + actor_id, + 2, + TaskEvent::TaskQueued { + run_id: run_id.clone(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ), + envelope( + task_id, + actor_id, + 3, + TaskEvent::RunStarted { + run_id: run_id.clone(), + }, + ), + ]) + .unwrap() +} + +fn plan_execution( + aggregate: &mut TaskAggregate, + task_id: &TaskId, + actor_id: &ActorId, + revision: u64, +) -> ExecutionId { + let execution_id = ExecutionId::new(); + aggregate + .apply(&envelope( + task_id, + actor_id, + revision, + TaskEvent::ExecutionPlanned { + execution_id: execution_id.clone(), + permit_id: PermitId::new(), + }, + )) + .unwrap(); + execution_id +} + +#[test] +fn reducer_accepts_success_lifecycle() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let run_id = RunId::new(); + let events = vec![ + submitted(&task_id, &actor_id), + envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: run_id.clone(), + runtime: RuntimeSelector { + runtime: BoundedName::new("cosh_core").unwrap(), + profile: None, + }, + }, + ), + envelope( + &task_id, + &actor_id, + 3, + TaskEvent::RunStarted { + run_id: run_id.clone(), + }, + ), + envelope( + &task_id, + &actor_id, + 4, + TaskEvent::RunSucceeded { + run_id: run_id.clone(), + }, + ), + envelope(&task_id, &actor_id, 5, TaskEvent::TaskSucceeded), + ]; + + let aggregate = TaskAggregate::replay(&events).unwrap(); + assert_eq!(aggregate.state(), TaskState::Succeeded); + assert_eq!(aggregate.revision(), 5); + assert_eq!(aggregate.active_run_id(), Some(&run_id)); +} + +#[test] +fn reducer_rejects_revision_gap_without_mutation() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let mut aggregate = TaskAggregate::replay(&[submitted(&task_id, &actor_id)]).unwrap(); + let before = aggregate.clone(); + let error = aggregate + .apply(&envelope( + &task_id, + &actor_id, + 3, + TaskEvent::TaskQueued { + run_id: RunId::new(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + )) + .unwrap_err(); + assert!(matches!(error, AggregateError::RevisionGap { .. })); + assert_eq!(aggregate, before); +} + +#[test] +fn reducer_rejects_in_memory_unsupported_schema_version() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let mut event = submitted(&task_id, &actor_id); + event.header.schema_version = CONTRACT_SCHEMA_VERSION + 1; + + assert!(matches!( + TaskAggregate::replay(&[event]), + Err(AggregateError::WrongSchemaVersion { .. }) + )); +} + +#[test] +fn approval_uses_explicit_waiting_state_and_denial_suspends() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let run_id = RunId::new(); + let approval_id = ApprovalId::new(); + let mut aggregate = TaskAggregate::replay(&[ + submitted(&task_id, &actor_id), + envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: run_id.clone(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ), + envelope( + &task_id, + &actor_id, + 3, + TaskEvent::RunStarted { + run_id: run_id.clone(), + }, + ), + ]) + .unwrap(); + + aggregate + .apply(&envelope( + &task_id, + &actor_id, + 4, + TaskEvent::ApprovalRequested { + approval: cosh_gateway_contracts::capability::ApprovalRequest { + approval_id: approval_id.clone(), + request_id: RequestId::new(), + task_id: task_id.clone(), + run_id, + summary: BoundedText::new("approve package update").unwrap(), + expires_at_ms: 100, + }, + }, + )) + .unwrap(); + assert_eq!(aggregate.state(), TaskState::WaitingApproval); + + aggregate + .apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::ApprovalResolved { + approval_id, + decision: ApprovalDecision::Deny, + }, + )) + .unwrap(); + assert_eq!(aggregate.state(), TaskState::Suspended); +} + +#[test] +fn terminal_task_cannot_reopen() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let run_id = RunId::new(); + let mut aggregate = TaskAggregate::replay(&[ + submitted(&task_id, &actor_id), + envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: run_id.clone(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ), + envelope( + &task_id, + &actor_id, + 3, + TaskEvent::RunStarted { + run_id: run_id.clone(), + }, + ), + envelope(&task_id, &actor_id, 4, TaskEvent::RunSucceeded { run_id }), + envelope(&task_id, &actor_id, 5, TaskEvent::TaskSucceeded), + ]) + .unwrap(); + + assert!(matches!( + aggregate.apply(&envelope( + &task_id, + &actor_id, + 6, + TaskEvent::TaskQueued { + run_id: RunId::new(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); +} + +#[test] +fn run_terminal_fact_rejects_later_runtime_events() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let run_id = RunId::new(); + let mut aggregate = TaskAggregate::replay(&[ + submitted(&task_id, &actor_id), + envelope( + &task_id, + &actor_id, + 2, + TaskEvent::TaskQueued { + run_id: run_id.clone(), + runtime: RuntimeSelector { + runtime: BoundedName::new("core").unwrap(), + profile: None, + }, + }, + ), + envelope( + &task_id, + &actor_id, + 3, + TaskEvent::RunStarted { + run_id: run_id.clone(), + }, + ), + envelope( + &task_id, + &actor_id, + 4, + TaskEvent::RunSucceeded { + run_id: run_id.clone(), + }, + ), + ]) + .unwrap(); + let before = aggregate.clone(); + + assert!(matches!( + aggregate.apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::RuntimeEventRecorded { + run_id: run_id.clone(), + update: RuntimeUpdate::Progress { + summary: BoundedText::new("late progress").unwrap(), + }, + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(aggregate, before); + assert!(matches!( + aggregate.apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::RunSucceeded { run_id }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(aggregate, before); +} + +#[test] +fn unresolved_planned_execution_blocks_run_completion() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let run_id = RunId::new(); + + let mut suspended = running(&task_id, &actor_id, &run_id); + plan_execution(&mut suspended, &task_id, &actor_id, 4); + let before = suspended.clone(); + assert!(matches!( + suspended.apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::RunSuspended { + run_id: run_id.clone(), + reason: SuspensionCode::RuntimeUnavailable, + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(suspended, before); + + let mut failed = before.clone(); + assert!(matches!( + failed.apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::RunFailed { + run_id: run_id.clone(), + error: ContractError::new( + "runtime_lost", + ErrorCategory::RuntimeUnavailable, + true, + "runtime lost", + ) + .unwrap(), + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(failed, before); + + let mut cancelled = before.clone(); + cancelled + .apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::CancellationRequested { + run_id: run_id.clone(), + cause: CancelReason::UserRequested, + }, + )) + .unwrap(); + let before_cancelled = cancelled.clone(); + assert!(matches!( + cancelled.apply(&envelope( + &task_id, + &actor_id, + 6, + TaskEvent::RunCancelled { + run_id, + stage: CancellationStage::Execution, + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(cancelled, before_cancelled); +} + +#[test] +fn retry_rejects_planned_or_uncertain_execution() { + let task_id = TaskId::new(); + let actor_id = ActorId::new(); + let run_id = RunId::new(); + let next_run_id = RunId::new(); + let approval_id = ApprovalId::new(); + let mut planned = running(&task_id, &actor_id, &run_id); + plan_execution(&mut planned, &task_id, &actor_id, 4); + planned + .apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::ApprovalRequested { + approval: cosh_gateway_contracts::capability::ApprovalRequest { + approval_id: approval_id.clone(), + request_id: RequestId::new(), + task_id: task_id.clone(), + run_id: run_id.clone(), + summary: BoundedText::new("approve execution").unwrap(), + expires_at_ms: 100, + }, + }, + )) + .unwrap(); + planned + .apply(&envelope( + &task_id, + &actor_id, + 6, + TaskEvent::ApprovalResolved { + approval_id, + decision: ApprovalDecision::Deny, + }, + )) + .unwrap(); + let before_planned_retry = planned.clone(); + assert!(matches!( + planned.apply(&envelope( + &task_id, + &actor_id, + 7, + TaskEvent::RunRetryQueued { + previous_run_id: run_id.clone(), + next_run_id: next_run_id.clone(), + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(planned, before_planned_retry); + + let mut uncertain = running(&task_id, &actor_id, &run_id); + let execution_id = plan_execution(&mut uncertain, &task_id, &actor_id, 4); + uncertain + .apply(&envelope( + &task_id, + &actor_id, + 5, + TaskEvent::ExecutionUncertain { + execution_id, + reason: UncertaintyCode::TransportLost, + }, + )) + .unwrap(); + let before_uncertain_retry = uncertain.clone(); + assert!(matches!( + uncertain.apply(&envelope( + &task_id, + &actor_id, + 6, + TaskEvent::RunRetryQueued { + previous_run_id: run_id, + next_run_id, + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(uncertain, before_uncertain_retry); + + let mut failed_uncertain = before_uncertain_retry.clone(); + let failed_run_id = failed_uncertain.active_run_id().unwrap().clone(); + assert!(matches!( + failed_uncertain.apply(&envelope( + &task_id, + &actor_id, + 6, + TaskEvent::RunFailed { + run_id: failed_run_id, + error: ContractError::new( + "uncertain_execution", + ErrorCategory::Conflict, + false, + "execution outcome is uncertain", + ) + .unwrap(), + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(failed_uncertain, before_uncertain_retry); + + let mut cancelled_uncertain = before_uncertain_retry.clone(); + let uncertain_run_id = cancelled_uncertain.active_run_id().unwrap().clone(); + cancelled_uncertain + .apply(&envelope( + &task_id, + &actor_id, + 6, + TaskEvent::CancellationRequested { + run_id: uncertain_run_id.clone(), + cause: CancelReason::UserRequested, + }, + )) + .unwrap(); + let before_uncertain_cancel = cancelled_uncertain.clone(); + assert!(matches!( + cancelled_uncertain.apply(&envelope( + &task_id, + &actor_id, + 7, + TaskEvent::RunCancelled { + run_id: uncertain_run_id, + stage: CancellationStage::Execution, + }, + )), + Err(AggregateError::InvalidTransition { .. }) + )); + assert_eq!(cancelled_uncertain, before_uncertain_cancel); +} diff --git a/src/cosh-ng/crates/cosh-platform/src/audit/query.rs b/src/cosh-ng/crates/cosh-platform/src/audit/query.rs index f586b5280a..638ef51a93 100644 --- a/src/cosh-ng/crates/cosh-platform/src/audit/query.rs +++ b/src/cosh-ng/crates/cosh-platform/src/audit/query.rs @@ -108,7 +108,7 @@ struct AuditCursorV1 { /// /// Returns `AuditCursorInvalid` for malformed, unsupported, or filter-mismatched /// cursors and `InvalidInput` for page sizes outside `1..=1000`. -// Keep the workspace Rust 1.74 MSRV; `Option::is_none_or` is newer. +// Keep the explicit predicates readable across the inclusive filter bounds. #[allow(clippy::unnecessary_map_or)] pub fn query_events( root: &Path, diff --git a/src/cosh-ng/crates/cosh-platform/src/audit/retention.rs b/src/cosh-ng/crates/cosh-platform/src/audit/retention.rs index ba9ad6cb79..e7548a26b8 100644 --- a/src/cosh-ng/crates/cosh-platform/src/audit/retention.rs +++ b/src/cosh-ng/crates/cosh-platform/src/audit/retention.rs @@ -99,7 +99,7 @@ pub enum RetentionExecutionStatus { /// /// The returned thread is intentionally detached by long-lived callers. Coordination remains /// bounded by `retention.lock`, so concurrent Core processes cannot delete the same segment. -// Keep the workspace Rust 1.74 MSRV; `Option::is_none_or` is newer. +// Keep the explicit predicates aligned with the query filter implementation. #[allow(clippy::unnecessary_map_or)] pub fn schedule_retention(root: PathBuf, settings: AuditSettings, component: AuditComponentName) { let now = Utc::now(); diff --git a/src/cosh-ng/crates/cosh-shell/src/tools/command_risk_parser.rs b/src/cosh-ng/crates/cosh-shell/src/tools/command_risk_parser.rs index fbeea08a0f..e82c5d9cdf 100644 --- a/src/cosh-ng/crates/cosh-shell/src/tools/command_risk_parser.rs +++ b/src/cosh-ng/crates/cosh-shell/src/tools/command_risk_parser.rs @@ -201,8 +201,7 @@ pub(super) fn parse_command(command: &str) -> ParsedCommand { dup_consumed += 1; has_dash = true; } - // Keep the workspace Rust 1.74 MSRV; `Option::is_none_or` - // is newer. + // Keep the boundary check explicit next to the parsed span. #[allow(clippy::unnecessary_map_or)] let boundary_ok = dup_lookahead.peek().map_or(true, |next| { // Word boundary = whitespace or a POSIX operator diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/README.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/README.md new file mode 100644 index 0000000000..43821cd149 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/README.md @@ -0,0 +1,141 @@ +# ACP v1 Phase 0-2 Planning Set + +[中文版](README_zh.md) + +## Status + +- Planning baseline: `up/main` at `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Candidate worktree: uncommitted implementation slices based on that baseline +- Document date: 2026-08-13 +- Overall Phase 0-2 readiness: **NOT ACCEPTED** +- Scope: architecture, acceptance criteria, and first-slice implementation evidence + +This set defines the first three delivery phases for evolving cosh-ng from an +interactive Agent shell into a local-first Agent OS gateway. ACP v1 is one +Agent Runtime adapter in this architecture. It is not the channel ingress, +durable task store, authorization system, or remote-control transport. + +None of these capabilities is available on the pinned `up/main` baseline. The +candidate worktree adds library-level foundations only; it is not a production +Gateway and has no distinct candidate commit SHA yet. + +## Candidate implementation snapshot + +The current worktree contains these partial foundations: + +- [`cosh-gateway-contracts`](../../../crates/cosh-gateway-contracts/src/lib.rs): + side-effect-free, versioned Task/Runtime/Capability contracts, bounded + leaf strings/digests, and distinct internal/external identities; +- [`cosh-gateway` Task and storage](../../../crates/cosh-gateway/src/task.rs): a + pure Task reducer plus a local single-writer SQLite WAL store that commits + events, projections, idempotency receipts, and Outbox intents together; +- [`RuntimeSupervisor`](../../../crates/cosh-gateway/src/runtime.rs): direct child + launch validation, bounded stdout/stderr, process-group escalation/reap, and + one process terminal observation; +- a strict codec for **private COSH JSONL control protocol v1**, including + exact initialization and typed runtime-local observations. It is not ACP; +- an initial [`AcpV1RuntimeBridge`](../../../crates/cosh-gateway/src/runtime/acp.rs) + that uses official Rust SDK 2.0.0 types for ACP wire v1, retains + `RuntimeSupervisor` as the sole process-lifecycle implementation, and covers initialization, one + session, text prompts, updates, permission correlation, and cancellation. +- a built-in [`ACP runtime profile resolver`](../../../crates/cosh-gateway/src/runtime/profile.rs) + for installed `codex-acp` and `claude-agent-acp` executables, with canonical + workspace/executable validation, an environment allowlist, and no + shell/package-runner/network bootstrap path. + +Capability contracts and a package-exposed, in-memory Broker/permit slice now +exist and pass targeted validation. The worktree still has +no Gateway daemon or network API, installed local ACP entrypoint, ACP session +driver with independent cancellation, production permission proxy, +`CoshCoreBridge` public event mapping, complete ACP-to-domain mapping, Shell +attachment, Web UI/API, DingTalk/Feishu adapter, restart/lease +orchestration, or complete production bypass closure. Existing `cosh-shell` +continues to own its PTY and compatibility cosh-core process path. + +The contract foundation does not yet apply aggregate admission limits to all +collections and envelopes, including vectors, batches, and Outbox payloads. + +## Product decision + +COSH should own the durable task and OS-governance boundary while allowing +Shell, Web, DingTalk, Feishu, and automation clients to attach through stable +ports. This differs from treating Terminal UI, provider processes, or ACP +sessions as the product's source of truth. + +ACP integration uses: + +- ACP wire protocol v1 with `initialize.protocolVersion = 1`; +- official Rust SDK 2.0.0 pinned exactly in `Cargo.lock`, with the cosh-ng + workspace and RPM build baseline raised to Rust 1.88; +- capability negotiation for every optional method or payload; +- local stdio transport in Phase 2; +- COSH-owned Gateway APIs for Web, channel, and cross-device traffic. + +ACP v2 and the draft Streamable HTTP transport are outside the Phase 0-2 +delivery contract. + +The ACP slice is a library-level interoperability probe with built-in launch +profiles, not an installed production entrypoint. Filesystem/terminal +callbacks, durable `AgentSessionId` binding, Task event mapping, +restart/resume, independent cancellation, and real-adapter conformance remain +outside the implemented slice. The narrower [local ACP MVP](phase-1/acp-mvp/design.md) +is specified separately from the full Phase 2 bridge. + +## Reading order + +1. [Cross-phase architecture](architecture.md) +2. [Warp comparison and positioning](warp-comparison.md) +3. Phase 0 module designs and readiness reports +4. Phase 1 module designs and readiness reports +5. Phase 2 module designs and readiness reports +6. [Overall acceptance report](acceptance-report.md) + +## Module inventory + +Every module has a design document and an acceptance report in English and +Chinese. Reports distinguish the pinned upstream baseline from partial +candidate-worktree evidence; neither document completeness nor a library slice +implies phase acceptance. + +| Phase | Module | Design | Acceptance | Target delivery result | +| --- | --- | --- | --- | --- | +| 0 | Protocol contracts | [Design](phase-0/protocol-contracts/design.md) | [Report](phase-0/protocol-contracts/acceptance.md) | Versioned domain and port contracts | +| 0 | Identity and correlation | [Design](phase-0/identity-correlation/design.md) | [Report](phase-0/identity-correlation/acceptance.md) | Non-ambiguous actor and lifecycle identity | +| 0 | Storage and supervision | [Design](phase-0/storage-supervision/design.md) | [Report](phase-0/storage-supervision/acceptance.md) | Accepted persistence and process-owner ADRs | +| 1 | Gateway API | [Design](phase-1/gateway-api/design.md) | [Report](phase-1/gateway-api/acceptance.md) | Local admission and task command surface | +| 1 | Task Execution Plane | [Design](phase-1/task-execution-plane/design.md) | [Report](phase-1/task-execution-plane/acceptance.md) | Durable Task, event, lease, and Outbox state | +| 1 | Capability Broker | [Design](phase-1/capability-broker/design.md) | [Report](phase-1/capability-broker/acceptance.md) | One governed boundary for OS side effects | +| 1 | CoshCore Bridge | [Design](phase-1/cosh-core-bridge/design.md) | [Report](phase-1/cosh-core-bridge/acceptance.md) | Existing JSONL runtime behind a neutral port | +| 1 | Local ACP Runtime MVP | [Design](phase-1/acp-mvp/design.md) | [Report](phase-1/acp-mvp/acceptance.md) | One installed local stdio text-prompt path | +| 2 | ACP Client Bridge | [Design](phase-2/acp-client-bridge/design.md) | [Report](phase-2/acp-client-bridge/acceptance.md) | ACP v1 stdio Agent interoperability | +| 2 | Shell Attachment | [Design](phase-2/shell-attachment/design.md) | [Report](phase-2/shell-attachment/acceptance.md) | Shell attach/detach without losing PTY ownership | +| 2 | Web and Presentation | [Design](phase-2/web-presentation/design.md) | [Report](phase-2/web-presentation/acceptance.md) | Replayable Web/API views and reliable delivery | + +## Phase gates + +| Gate | Must be true before exit | Must not be deferred | +| --- | --- | --- | +| G0 Contract freeze | Schemas, ID invariants, capability vocabulary, persistence ADR, supervision ADR, fixtures, and compatibility policy are reviewed | Runtime-specific objects do not leak into Gateway or Task contracts | +| G1 Local durable gateway | Task state survives restart; command/event/outbox transaction rules hold; every OS write requires a target-bound permit; cosh-core is reachable through the Runtime Port | No API handler, presenter, or Agent bridge can write Task state or execute OS actions directly | +| GM Local ACP Runtime MVP | One installed local entrypoint runs one canonical workspace/session/active text prompt through `codex-acp` or `claude-agent-acp`; independent cancel, once-only permission decisions, fail-closed transport, and real-adapter conformance pass | No native Codex/Claude ACP assumption, package-runner/network bootstrap, filesystem/terminal capability, load/resume, Web/daemon dependency, or persistent permission rule | +| G2 ACP and attachments | ACP v1 conformance passes over stdio; permission and terminal requests enter COSH governance; Shell and Web can attach, detach, replay, approve, and cancel against the same Task | ACP is not used as a remote channel protocol, and ACP Session ID is never used as Task ID | + +## Change-control rules + +- A phase cannot redefine an earlier frozen identifier or event without a + compatibility decision and updated fixtures. +- Each implementation pull request must cite the module acceptance rows it + satisfies and attach the exact commands and evidence. +- Acceptance evidence must record the tested commit. A design review alone + cannot mark runtime behavior as passed. +- Full provider, ECS, or manual Terminal validation remains a separately + requested gate; the planning documents do not imply that it has run. + +## External references + +- [ACP architecture](https://agentclientprotocol.com/get-started/architecture) +- [ACP v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization) +- [ACP v1 transports](https://agentclientprotocol.com/protocol/v1/transports) +- [ACP updates](https://agentclientprotocol.com/updates) +- [Warp Oz Platform](https://docs.warp.dev/platform/overview/) +- [Warp architecture and deployment](https://docs.warp.dev/enterprise/enterprise-features/architecture-and-deployment) diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/README_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/README_zh.md new file mode 100644 index 0000000000..68a90564d4 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/README_zh.md @@ -0,0 +1,123 @@ +# ACP v1 Phase 0-2 规划集 + +[English](README.md) + +## 状态 + +- 规划基线:`up/main` 的 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 候选工作树:基于该基线的未提交实现切片 +- 文档日期:2026-08-13 +- Phase 0-2 总体就绪度:**NOT ACCEPTED** +- 范围:架构、验收标准与第一轮实现证据 + +本规划集定义 cosh-ng 从交互式 Agent Shell 演进为本地优先 Agent OS Gateway +的前三个交付阶段。ACP v1 在这套架构中只是一个 Agent Runtime Adapter, +不承担渠道入口、持久 Task 存储、授权系统或远程控制传输。 + +固定的 `up/main` 基线上不具备这些能力。候选工作树只增加库级基础,不是生产 +Gateway,而且尚无独立的候选 commit SHA。 + +## 候选实现快照 + +当前工作树包含以下局部基础: + +- [`cosh-gateway-contracts`](../../../crates/cosh-gateway-contracts/src/lib.rs):无副作用且有版本的 + Task/Runtime/Capability contract、有界 leaf string/digest,以及相互独立的内部和外部 identity; +- [`cosh-gateway` Task 与 storage](../../../crates/cosh-gateway/src/task.rs):纯 Task reducer 与 local + single-writer SQLite WAL store,在同一 transaction 中提交 event、projection、idempotency receipt + 和 Outbox intent; +- [`RuntimeSupervisor`](../../../crates/cosh-gateway/src/runtime.rs):direct child launch validation、 + bounded stdout/stderr、process-group escalation/reap 与一次 process terminal observation; +- **private COSH JSONL control protocol v1** 的严格 codec,包含 exact initialization 与 typed + runtime-local observation。它不是 ACP; +- 初始 [`AcpV1RuntimeBridge`](../../../crates/cosh-gateway/src/runtime/acp.rs),使用官方 Rust SDK + 2.0.0 类型承载 ACP wire v1,继续由 `RuntimeSupervisor` 提供唯一 process lifecycle implementation,并覆盖 initialization、 + 单 session、text prompt、update、permission correlation 与 cancellation。 +- 内置 [`ACP Runtime profile resolver`](../../../crates/cosh-gateway/src/runtime/profile.rs), + 仅解析已安装的 `codex-acp` 与 `claude-agent-acp`,校验 canonical workspace/executable, + 使用 environment allowlist,且没有 shell、package runner 或 network bootstrap 路径。 + +Capability contract 与 package-exposed in-memory Broker/permit slice 已存在并通过 targeted +validation。工作树仍没有 Gateway daemon 或 network API、已安装的 local ACP entrypoint、支持独立 +取消的 ACP Session Driver、production Permission Proxy、`CoshCoreBridge` public event mapping、完整 +ACP-to-domain mapping、Shell Attachment、Web UI/API、钉钉/飞书 Adapter、restart/lease orchestration,也没有完成所有 +production bypass closure。现有 `cosh-shell` 继续拥有 PTY 与兼容 cosh-core process path。 + +Contract 基础尚未对全部 collection 与 envelope 应用 aggregate admission limit,包括 vector、batch +和 Outbox payload。 + +## 产品决策 + +COSH 应当拥有持久 Task 和 OS 治理边界,并允许 Shell、Web、钉钉、飞书和 +自动化客户端通过稳定 Port 接入。Terminal UI、provider 进程或 ACP session +都不能成为产品状态的事实来源。 + +ACP 集成采用以下约束: + +- ACP wire protocol v1,`initialize.protocolVersion = 1`; +- 在 `Cargo.lock` 中准确固定官方 Rust SDK 2.0.0,并把 cosh-ng workspace 与 RPM + build baseline 提升到 Rust 1.88; +- 每一项可选 method 或 payload 都必须经过 capability negotiation; +- Phase 2 首先实现本地 stdio transport; +- Web、渠道和跨设备流量使用 COSH 自有 Gateway API。 + +ACP v2 和仍处于草案状态的 Streamable HTTP transport 不属于 Phase 0-2 +交付契约。 + +当前 ACP slice 是带内置 launch profile 的 library-level interoperability probe,不是已安装的 +production entrypoint。Filesystem/terminal callback、持久 `AgentSessionId` binding、Task event +mapping、restart/resume、独立取消与 real-adapter conformance 仍不在已实现切片内。更窄的 +[Local ACP MVP](phase-1/acp-mvp/design_zh.md) 与完整 Phase 2 Bridge 分开定义。 + +## 阅读顺序 + +1. [跨阶段架构](architecture_zh.md) +2. [Warp 对比与产品定位](warp-comparison_zh.md) +3. Phase 0 各模块设计与就绪度报告 +4. Phase 1 各模块设计与就绪度报告 +5. Phase 2 各模块设计与就绪度报告 +6. [总体验收报告](acceptance-report_zh.md) + +## 模块清单 + +每个模块都有中英文设计文档和验收报告。报告区分固定的上游基线与候选工作树局部证据; +文档完整或存在一个 library slice 都不表示阶段通过。 + +| 阶段 | 模块 | 设计 | 验收 | 目标交付结果 | +| --- | --- | --- | --- | --- | +| 0 | Protocol Contracts | [设计](phase-0/protocol-contracts/design_zh.md) | [报告](phase-0/protocol-contracts/acceptance_zh.md) | 有版本的领域与 Port 契约 | +| 0 | Identity and Correlation | [设计](phase-0/identity-correlation/design_zh.md) | [报告](phase-0/identity-correlation/acceptance_zh.md) | 无歧义的 actor 与生命周期身份 | +| 0 | Storage and Supervision | [设计](phase-0/storage-supervision/design_zh.md) | [报告](phase-0/storage-supervision/acceptance_zh.md) | 通过评审的持久化与进程 owner ADR | +| 1 | Gateway API | [设计](phase-1/gateway-api/design_zh.md) | [报告](phase-1/gateway-api/acceptance_zh.md) | 本地 admission 和 Task command 接口 | +| 1 | Task Execution Plane | [设计](phase-1/task-execution-plane/design_zh.md) | [报告](phase-1/task-execution-plane/acceptance_zh.md) | 持久 Task、event、lease 与 Outbox 状态 | +| 1 | Capability Broker | [设计](phase-1/capability-broker/design_zh.md) | [报告](phase-1/capability-broker/acceptance_zh.md) | 所有 OS 副作用的统一治理边界 | +| 1 | CoshCore Bridge | [设计](phase-1/cosh-core-bridge/design_zh.md) | [报告](phase-1/cosh-core-bridge/acceptance_zh.md) | 中立 Port 后的现有 JSONL Runtime | +| 1 | Local ACP Runtime MVP | [设计](phase-1/acp-mvp/design_zh.md) | [报告](phase-1/acp-mvp/acceptance_zh.md) | 单个已安装 local stdio text-prompt 路径 | +| 2 | ACP Client Bridge | [设计](phase-2/acp-client-bridge/design_zh.md) | [报告](phase-2/acp-client-bridge/acceptance_zh.md) | ACP v1 stdio Agent 互操作 | +| 2 | Shell Attachment | [设计](phase-2/shell-attachment/design_zh.md) | [报告](phase-2/shell-attachment/acceptance_zh.md) | 保留 PTY ownership 的 Shell attach/detach | +| 2 | Web and Presentation | [设计](phase-2/web-presentation/design_zh.md) | [报告](phase-2/web-presentation/acceptance_zh.md) | 可重放 Web/API view 与可靠投递 | + +## 阶段 Gate + +| Gate | 退出阶段前必须满足 | 不得后移的问题 | +| --- | --- | --- | +| G0 契约冻结 | Schema、ID invariant、capability 词表、持久化 ADR、监督 ADR、fixture 和兼容策略完成评审 | Runtime 专用对象不得泄漏到 Gateway 或 Task 契约 | +| G1 本地持久 Gateway | Task 可在重启后恢复;command/event/outbox transaction 规则成立;每次 OS write 都需要 target-bound permit;可通过 Runtime Port 调用 cosh-core | API handler、presenter 或 Agent bridge 均不能直接写 Task 状态或执行 OS action | +| GM Local ACP Runtime MVP | 一个已安装 local entrypoint 在一个 canonical workspace/session/active text prompt 范围内运行 `codex-acp` 或 `claude-agent-acp`;独立 cancel、once-only permission decision、fail-closed transport 与 real-adapter conformance 通过 | 不假定 Codex/Claude 原生 ACP,不允许 package runner/network bootstrap、filesystem/terminal capability、load/resume、Web/daemon dependency 或持久 permission rule | +| G2 ACP 与 Attachment | ACP v1 stdio conformance 通过;permission 和 terminal request 进入 COSH 治理;Shell 与 Web 面向同一 Task 完成 attach、detach、replay、approval 和 cancel | 不用 ACP 传输远端渠道;ACP Session ID 绝不能充当 Task ID | + +## 变更控制 + +- 后续阶段不得无兼容决策地重定义已经冻结的 ID 或 event,并且任何变化都要更新 fixture。 +- 每个实现 PR 必须引用自己满足的模块验收项,并附精确命令与证据。 +- 验收证据必须记录被测 commit。只完成设计评审不能把 Runtime 行为标记为通过。 +- 完整 provider、ECS 或手工 Terminal 验证属于需要另行明确请求的 gate;本规划集不表示这些验证已经执行。 + +## 外部资料 + +- [ACP 架构](https://agentclientprotocol.com/get-started/architecture) +- [ACP v1 初始化](https://agentclientprotocol.com/protocol/v1/initialization) +- [ACP v1 Transports](https://agentclientprotocol.com/protocol/v1/transports) +- [ACP 更新](https://agentclientprotocol.com/updates) +- [Warp Oz Platform](https://docs.warp.dev/platform/overview/) +- [Warp 架构与部署](https://docs.warp.dev/enterprise/enterprise-features/architecture-and-deployment) diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report.md new file mode 100644 index 0000000000..db2bff8fb4 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report.md @@ -0,0 +1,248 @@ +# Overall Acceptance Report + +[中文版](acceptance-report_zh.md) + +## Report identity + +| Field | Value | +| --- | --- | +| Baseline | `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` (`up/main`, 2026-08-12 fetch) | +| Candidate | Uncommitted shared worktree based on the baseline; no distinct candidate SHA yet | +| Scope | Phase 0, Phase 1, and Phase 2 architecture readiness | +| Code changes assessed | Contracts, Task reducer/SQLite store, Runtime/private JSONL, ACP v1 bridge/profile slices, and partial Capability slices | +| Overall implementation status | **NOT ACCEPTED** | +| Document integration status | **PASS** after the checks recorded below; not a phase gate | + +## Status vocabulary + +| Status | Meaning | +| --- | --- | +| `PASS` | Candidate-commit evidence satisfies the stated criterion | +| `PARTIAL` | A bounded source/test slice exists, but the module exit criteria or integration path remain incomplete | +| `FAIL` | Implemented behavior was exercised and violated the criterion | +| `NOT IMPLEMENTED` | Required production surface does not exist on the assessed commit | +| `BLOCKED` | The surface exists, but the required environment or prior decision prevents a valid test | +| `NOT RUN` | A test was applicable but was not requested or executed | + +`NOT IMPLEMENTED` is not softened to `BLOCKED`. A completed design is not +runtime evidence. A `PARTIAL` library slice is not a production capability. + +## Baseline findings + +The baseline already supplies useful implementation foundations: + +- five Rust crates with explicit dependency direction; +- a standalone `cosh-shell` that owns PTY and Agent child lifecycle; +- an exact-version internal cosh-core JSONL initialization contract; +- streamed Agent events, approvals, questions, cancellation, session recovery, + audit identity, and bounded evidence patterns; +- workspace-scoped model conversation persistence; +- typed package, service, checkpoint, and audit operations. + +The baseline source and workspace manifests contain no production Gateway +daemon, Task aggregate/store/event store, execution lease, Outbox, Capability +Broker, ACP client dependency or implementation, Web attachment API, or +channel adapter. All Phase 1 and Phase 2 product gates therefore start at +`NOT IMPLEMENTED` even where an existing component can be adapted. + +## Candidate-worktree findings + +The current worktree adds implementation foundations that are absent from the +pinned baseline: + +| Slice | Implemented evidence | Still missing for acceptance | +| --- | --- | --- | +| Neutral contracts and identities | Side-effect-free `cosh-gateway-contracts` with versioned headers, bounded leaf strings/digests/errors, distinct ID newtypes, Task/Runtime events, Capability/Approval/Permit shapes, and serde validation | Aggregate collection/envelope admission limits, canonical schema/golden corpus, complete compatibility manifest, ownership ADR acceptance, authenticated identity resolver, and durable parent/fence enforcement | +| Task reducer | `TaskAggregate` validates schema/correlation, consecutive revisions, active Run, approval/execution lifecycle, cancellation, retry, and terminal transitions without partial mutation | `TaskCoordinator`, command routing, runner leases, durable input, event-admission fencing, complete transition/property/race suite, and restart orchestration | +| SQLite Task store | Local single-writer SQLite schema v1 with WAL/FULL policy, checked migrations, Task projection/event/receipt/Outbox transaction, revision/idempotency checks, and path/symlink/permission validation | Backup/restore, migration artifacts, disk-full/corruption/kill-point suites, Outbox worker/lease recovery, daemon restart reconciliation, and full companion-file race hardening | +| Runtime and private core transport | `RuntimeSupervisor` validates direct launches, clears inherited environment, bounds stdout/stderr, owns process-group shutdown/reap, and emits one process terminal; private COSH JSONL codec negotiates exact v1 and produces typed local observations | Integrated `CoshCoreBridge`, public contract event mapping/fencing/backpressure, restart/deadline policy, provider-session binding, complete descendant/race fixtures, and migration from Shell ownership | +| ACP v1 first slice | Official SDK 2.0.0 is pinned with Rust 1.88; codec/bridge negotiate wire v1; a bounded driver provides independent cancellation; built-in profiles resolve installed adapters with canonical paths and allowlisted environments | Installed COSH entrypoint, local permission UI/evidence, durable governance/mapping, restart/resume, broader conformance, and real-adapter evidence | +| Capability | Neutral contracts plus a package-exposed Broker and mutex-atomic in-memory single-use permit store; targeted tests cover parent, expiry, target, operation, policy, execution binding, and concurrent claims | Durable/audited permit ledger, immutable target resolver, execution target/verifier, reconciliation, and closure of legacy CLI/core/Shell bypasses | + +No candidate code implements a Gateway daemon, authenticated Unix/network +API, installed ACP entrypoint, Shell attachment, Web/API presentation, +or channel adapter. The private COSH JSONL v1 codec remains separate from the +new ACP v1 bridge. + +## Module readiness summary + +Each detailed report is authoritative for its module. + +| Phase | Module | Candidate readiness | Report | +| --- | --- | --- | --- | +| 0 | Protocol contracts | `PARTIAL`; typed leaf contracts pass targeted checks, while frozen schemas/fixtures and full ports remain | [Report](phase-0/protocol-contracts/acceptance.md) | +| 0 | Identity and correlation | `PARTIAL`; distinct IDs/bindings exist, while authenticated/durable mapping and fences remain | [Report](phase-0/identity-correlation/acceptance.md) | +| 0 | Storage and supervision | `PARTIAL`; SQLite/store and supervisor foundations exist, while recovery, fencing, process-tree, and ownership migration remain | [Report](phase-0/storage-supervision/acceptance.md) | +| 1 | Gateway API | `NOT IMPLEMENTED` | [Report](phase-1/gateway-api/acceptance.md) | +| 1 | Task Execution Plane | `PARTIAL`; reducer and atomic local store exist, but coordinator/leases/restart path do not | [Report](phase-1/task-execution-plane/acceptance.md) | +| 1 | Capability Broker | `PARTIAL`; package-exposed in-memory slice passes targeted tests, but is not a universal production gate | [Report](phase-1/capability-broker/acceptance.md) | +| 1 | CoshCore Bridge | `PARTIAL`; supervisor/private codec exist, but the bridge/public mapping does not | [Report](phase-1/cosh-core-bridge/acceptance.md) | +| 1 | Local ACP Runtime MVP | `PARTIAL`; codec, bridge, bounded driver, independent cancel, fake-Agent tests, and fixed profiles exist, but the installed entrypoint, permission UI/evidence, and real-adapter proof do not | [Report](phase-1/acp-mvp/acceptance.md) | +| 2 | ACP Client Bridge | `PARTIAL`; official v1 codec and supervised stdio slice pass focused tests, while domain/governance/recovery integration remains | [Report](phase-2/acp-client-bridge/acceptance.md) | +| 2 | Shell Attachment | `NOT IMPLEMENTED`; direct Shell mode exists | [Report](phase-2/shell-attachment/acceptance.md) | +| 2 | Web and Presentation | `NOT IMPLEMENTED` | [Report](phase-2/web-presentation/acceptance.md) | + +## Phase gate report + +### G0: contract freeze + +Current status: **NOT ACCEPTED**. + +Exit requires all of the following: + +- canonical v1 schemas for ingress, identity, Task commands/events, approval, + capability, permits, execution, Runtime events, presentation, delivery, and + error envelopes; +- machine-readable fixtures with backward/forward compatibility tests; +- explicit ID generation, authority, correlation, and redaction invariants; +- accepted persistence ADR, migration policy, and backup/recovery contract; +- accepted process-supervision ADR with one owner per child process; +- ACP v1 feasibility fixture proving SDK and wire-version separation, with + official SDK 2.0.0 and Rust 1.88 recorded independently from stable wire v1; +- dependency and crate ownership decision that preserves the existing Shell + boundary or records its deliberate replacement. + +No Phase 1 production API may freeze its own duplicate contract before G0. + +The candidate types, SQLite schema, supervision primitives, and ACP feasibility +slice reduce G0 implementation risk, but missing canonical fixtures, ADR +sign-off, identity admission, and recovery artifacts keep G0 rejected. + +### G1: local durable Gateway + +Current status: **NOT ACCEPTED; partial library foundations only**. + +Exit requires: + +- local authenticated Unix-socket API and idempotent task submission; +- durable Task command/event/snapshot behavior across process restart; +- atomic Task event and Outbox append; +- renewable runner leases and explicit uncertain-side-effect handling; +- a universal Capability Broker with target-bound, expiring, single-operation + permits; +- deterministic typed execution through platform operators; +- cosh-core lifecycle accessed only through `AgentRuntimePort`; +- cancellation, approval race, crash recovery, and audit-correlation tests; +- no direct OS execution from handlers, presenters, or Agent bridges. + +The reducer/store/supervisor slices do not provide a daemon, API, +TaskCoordinator, runner lease/recovery loop, integrated Runtime Port, or a +universal production Capability gate. They therefore do not satisfy G1. + +### GM: local ACP Runtime MVP + +Current status: **NOT ACCEPTED; partial library foundations only**. + +Exit requires one installed COSH entrypoint to run exactly one canonical +workspace, ACP connection/session, and active bounded text prompt through an +installed `codex-acp` or `claude-agent-acp`. A session driver must keep cancel +independent of a silent or blocked stdout reader, transport failures must fail +closed, and the local permission proxy must expose only correlated +`allow_once` and `reject_once` decisions. At least one real adapter must pass +initialize, multi-chunk prompt, terminal result, independent cancel, allow +once, and reject once on the same candidate revision. + +Native Codex/Claude ACP support, `npx` or other package runners, network +bootstrap, filesystem/terminal callbacks, load/resume, Web, and the Gateway +daemon are outside this MVP and cannot be used to satisfy it. + +### G2: ACP and interactive attachments + +Current status: **NOT ACCEPTED; first ACP library slice only**. + +Exit requires: + +- ACP v1 initialization and capability negotiation over local stdio; +- baseline ACP session and streaming behavior mapped to Runtime types; +- ACP permission, filesystem, and terminal requests routed through durable + approval and Capability Broker paths; +- incompatible protocol, missing capability, malformed stdout, child exit, + cancellation, and session recovery conformance cases; +- Shell attach/detach/replay while preserving PTY ownership and direct mode; +- Web/API cursored replay, approval, cancellation, and safe output views; +- Outbox retry and stable delivery receipt semantics; +- proof that Task, Run, ACP session, Shell session, request, tool, and execution + identities remain distinct. + +## Required evidence package for implementation acceptance + +Every module implementation report must include: + +1. candidate branch and full commit SHA; +2. reviewed requirement rows and source links; +3. exact commands, environment, test count, and results; +4. versioned fixtures or captured sanitized protocol transcripts; +5. negative and race/failure cases, not only success paths; +6. any untested provider, ECS, platform, or manual UI paths; +7. rollback or compatibility result; +8. reviewer sign-off for security- or wire-contract decisions. + +Evidence must not contain credentials, raw prompts, private terminal output, +host identifiers, or unrestricted environment values. + +## Cross-module acceptance scenarios + +These scenarios cannot be closed by a single unit test. + +| Scenario | Expected evidence | +| --- | --- | +| Duplicate DingTalk/Web/CLI submission | One Task state effect and the same returned `TaskId` | +| Gateway crash after event commit | Task and Outbox recover without duplicating the side effect | +| Runner lease expires during an OS write | Execution becomes uncertain or reconciled; it is not blindly replayed | +| Two approval callbacks race | One terminal decision wins and both callers receive the committed state | +| cosh-core exits during a turn | One terminal Runtime event and deterministic Task suspension/failure | +| ACP Agent requests terminal execution | Broker decision and permit precede target execution; full IDs reach audit | +| Shell detaches during approval | Task remains waiting; another authorized client can resolve it without owning the PTY | +| Web delivery is unavailable | Task continues according to state; Outbox retries delivery independently | +| Provider network becomes unavailable | Explicit suspend or configured local fallback without policy downgrade | +| Gateway restarts with active attachments | Clients replay from cursors; no in-memory UI state is treated as durable truth | + +## Scope-proportional candidate validation + +Implementation owners and the integration owner ran targeted package checks +for the Rust slices present in the shared worktree. The documentation +integration ran the corresponding bilingual and repository-document checks: + +- inspect bilingual file pairing and semantic parity; +- validate relative Markdown links; +- run `git diff --check`; +- check that commands and implementation claims agree with baseline and + candidate source; +- preserve exact commands and results without promoting package evidence to a + full-system gate. + +No full workspace test, workspace-wide Clippy, release build, ECS, provider, +or manual Terminal gate is claimed. + +### Recorded targeted implementation evidence + +| Slice | Recorded command/result | +| --- | --- | +| Contracts | `cargo test --locked --package cosh-gateway-contracts`: 6 integration tests passed; unit/doc-test targets passed. Package fmt, all-target Clippy, rustdoc, and dependency-tree checks also passed. | +| Gateway library integration | `cargo +1.88.0 test --locked --package cosh-gateway --no-fail-fast`: 84 passed, 0 failed. This is one package suite, not a workspace/full-system gate. | +| Task reducer | The aggregate suite includes 15 focused transition tests, including unresolved and uncertain execution guards. | +| SQLite storage | The storage suite includes 15 focused tests, including normal load/commit snapshot replay verification. | +| Runtime and ACP | The package suite covers private JSONL, ACP v1 codec/bridge, fixed profiles, bounded I/O/supervision, and the independently cancellable session driver. Gateway all-target Clippy and package rustdoc passed. | +| Capability | `cargo +1.88.0 test --locked --package cosh-gateway capability --no-fail-fast`: 12 passed, 0 failed. This validates the in-memory decision/permit slice only. | + +### Planning-document evidence + +| Check | Result | +| --- | --- | +| Per-module package | PASS: every module has English and Chinese `design` and `acceptance` documents | +| Repository documentation lint | PASS: `bash scripts/docs-lint.sh` | +| Repository link check | PASS: `python3 scripts/docs-link-check.py` | +| Complete owned-document link check | PASS: every relative link in the eight aggregate/developer-guide files resolves | +| Markdown hygiene | PASS: `git diff --check` and owned-file trailing-whitespace checks | +| Implementation-claim review | PASS: baseline and candidate claims are separated; the ACP codec/bridge/profile/driver foundation is distinguished from the missing installed entrypoint, production governance, and real-adapter evidence | + +The recorded code results are scope-proportional package gates, not full +workspace or live-system validation. ECS validation, provider calls, and +manual Terminal UX were not run. + +## Acceptance owner and update rule + +The architecture owner maintains this overall report. Module owners update +their detailed reports in the implementation pull request that produces the +evidence. A phase is accepted only when every module report reaches its exit +criteria and this report records the exact aggregate candidate commit. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report_zh.md new file mode 100644 index 0000000000..c2a0a6a350 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/acceptance-report_zh.md @@ -0,0 +1,228 @@ +# 总体验收报告 + +[English](acceptance-report.md) + +## 报告身份 + +| 字段 | 值 | +| --- | --- | +| 基线 | `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`(2026-08-12 获取的 `up/main`) | +| 候选 | 基于该基线的未提交共享工作树;尚无独立候选 SHA | +| 范围 | Phase 0、Phase 1 与 Phase 2 架构就绪度 | +| 被评估的代码变更 | Contracts、Task reducer/SQLite store、Runtime/private JSONL、ACP v1 Bridge/profile 切片与局部 Capability slice | +| 总体实现状态 | **NOT ACCEPTED** | +| 文档集成状态 | **PASS**(通过下述检查;不等于阶段 Gate) | + +## 状态词表 + +| 状态 | 含义 | +| --- | --- | +| `PASS` | 候选 commit 的证据满足验收项 | +| `PARTIAL` | 已有有界源码/测试切片,但模块 exit criteria 或集成路径仍不完整 | +| `FAIL` | 已实现行为经过验证并违反验收项 | +| `NOT IMPLEMENTED` | 被评估 commit 不存在所需生产接口 | +| `BLOCKED` | 接口存在,但环境或前置决策阻止有效验证 | +| `NOT RUN` | 验证适用,但没有被请求或执行 | + +`NOT IMPLEMENTED` 不能弱化成 `BLOCKED`。完成设计不构成 Runtime 证据,`PARTIAL` library slice +也不是 production capability。 + +## 基线结论 + +基线已经提供以下可复用基础: + +- 五个 Rust crate 和显式依赖方向; +- 拥有 PTY 与 Agent 子进程生命周期的独立 `cosh-shell`; +- 精确版本协商的 cosh-core 内部 JSONL 初始化契约; +- 流式 Agent event、approval、question、cancellation、session recovery、audit identity + 与有界 evidence 模式; +- 按 workspace 保存模型 conversation; +- 类型化 package、service、checkpoint 与 audit 操作。 + +基线源码和 workspace manifest 不包含生产 Gateway daemon、Task aggregate/store/event +store、execution lease、Outbox、Capability Broker、ACP Client dependency 或实现、Web +Attachment API 或 Channel Adapter。因此所有 Phase 1 与 Phase 2 产品 Gate 的初始状态 +都是 `NOT IMPLEMENTED`,即使已有组件可以改造成基础实现。 + +## 候选工作树结论 + +当前工作树增加了固定基线中不存在的实现基础: + +| 切片 | 已实现证据 | 通过验收仍缺少 | +| --- | --- | --- | +| 中立 contract 与 identity | 无副作用的 `cosh-gateway-contracts`,包含 versioned header、有界 leaf string/digest/error、不同 ID newtype、Task/Runtime event、Capability/Approval/Permit shape 与 serde validation | Aggregate collection/envelope admission limit、canonical schema/golden corpus、完整 compatibility manifest、ownership ADR 验收、authenticated identity resolver 与 durable parent/fence enforcement | +| Task reducer | `TaskAggregate` 校验 schema/correlation、连续 revision、active Run、approval/execution lifecycle、cancel、retry 与 terminal transition,失败时不产生 partial mutation | `TaskCoordinator`、command routing、runner lease、durable input、event-admission fencing、完整 transition/property/race suite 与 restart orchestration | +| SQLite Task store | Local single-writer SQLite schema v1,包含 WAL/FULL policy、checked migration、Task projection/event/receipt/Outbox transaction、revision/idempotency check 与 path/symlink/permission validation | Backup/restore、migration artifact、disk-full/corruption/kill-point suite、Outbox worker/lease recovery、daemon restart reconciliation 与完整 companion-file race hardening | +| Runtime 与 private core transport | `RuntimeSupervisor` 校验 direct launch、清除 inherited environment、限制 stdout/stderr、独占 process-group shutdown/reap 并产生一次 process terminal;private COSH JSONL codec 协商 exact v1 并生成 typed local observation | 集成 `CoshCoreBridge`、public contract event mapping/fencing/backpressure、restart/deadline policy、provider-session binding、完整 descendant/race fixture 与 Shell ownership migration | +| ACP v1 第一轮切片 | 准确固定官方 SDK 2.0.0 与 Rust 1.88;codec/Bridge 协商 wire v1;有界 Driver 提供独立 cancel;内置 profile 以 canonical path 与 environment allowlist 解析已安装 Adapter | 已安装 COSH entrypoint、local permission UI/evidence、durable governance/mapping、restart/resume、更广 conformance 与 real-adapter 证据 | +| Capability | 中立 contract、package-exposed Broker 与 mutex-atomic in-memory single-use permit store 已存在;targeted test 覆盖 parent、expiry、target、operation、policy、execution binding 与 concurrent claim | Durable/audited permit ledger、immutable target resolver、execution target/verifier、reconciliation 与关闭 legacy CLI/core/Shell bypass | + +候选代码没有实现 Gateway daemon、authenticated Unix/network API、已安装 ACP entrypoint、 +Shell Attachment、Web/API Presentation 或 Channel Adapter。Private COSH JSONL v1 +codec 与新增 ACP v1 Bridge 继续保持独立。 + +## 模块就绪度摘要 + +每个模块的详细报告是该模块的权威记录。 + +| 阶段 | 模块 | 候选就绪度 | 报告 | +| --- | --- | --- | --- | +| 0 | Protocol Contracts | `PARTIAL`;typed leaf contract 通过 targeted check,frozen schema/fixture 与完整 port 仍缺 | [报告](phase-0/protocol-contracts/acceptance_zh.md) | +| 0 | Identity and Correlation | `PARTIAL`;已有独立 ID/binding,authenticated/durable mapping 与 fence 仍缺 | [报告](phase-0/identity-correlation/acceptance_zh.md) | +| 0 | Storage and Supervision | `PARTIAL`;SQLite/store 与 supervisor 基础存在,recovery、fencing、process-tree 与 ownership migration 仍缺 | [报告](phase-0/storage-supervision/acceptance_zh.md) | +| 1 | Gateway API | `NOT IMPLEMENTED` | [报告](phase-1/gateway-api/acceptance_zh.md) | +| 1 | Task Execution Plane | `PARTIAL`;已有 reducer 与 atomic local store,coordinator/lease/restart path 仍缺 | [报告](phase-1/task-execution-plane/acceptance_zh.md) | +| 1 | Capability Broker | `PARTIAL`;package-exposed in-memory slice 通过 targeted test,但还不是通用 production gate | [报告](phase-1/capability-broker/acceptance_zh.md) | +| 1 | CoshCore Bridge | `PARTIAL`;已有 supervisor/private codec,但 Bridge/public mapping 不存在 | [报告](phase-1/cosh-core-bridge/acceptance_zh.md) | +| 1 | Local ACP Runtime MVP | `PARTIAL`;codec、Bridge、有界 Driver、独立 cancel、fake-Agent test 与固定 profile 已存在,但 installed entrypoint、permission UI/evidence 与 real-adapter proof 不存在 | [报告](phase-1/acp-mvp/acceptance_zh.md) | +| 2 | ACP Client Bridge | `PARTIAL`;官方 v1 codec 与 supervised stdio 切片通过 focused test,domain/governance/recovery integration 仍缺 | [报告](phase-2/acp-client-bridge/acceptance_zh.md) | +| 2 | Shell Attachment | `NOT IMPLEMENTED`;当前存在 direct Shell mode | [报告](phase-2/shell-attachment/acceptance_zh.md) | +| 2 | Web and Presentation | `NOT IMPLEMENTED` | [报告](phase-2/web-presentation/acceptance_zh.md) | + +## 阶段 Gate 报告 + +### G0:Contract Freeze + +当前状态:**NOT ACCEPTED**。 + +退出 Gate 必须满足: + +- Ingress、Identity、Task command/event、Approval、Capability、Permit、Execution、 + Runtime event、Presentation、Delivery 和 Error envelope 的 v1 canonical schema; +- 带 backward/forward compatibility 测试的 machine-readable fixture; +- 明确 ID generation、authority、correlation 和 redaction invariant; +- 通过评审的 persistence ADR、migration policy 与 backup/recovery contract; +- 通过评审的 process supervision ADR,每个子进程只有一个 owner; +- ACP v1 feasibility fixture 证明 SDK 与 wire version 分离,分别记录官方 SDK + 2.0.0、Rust 1.88 和稳定 wire v1; +- Dependency 与 crate ownership 决策,保持现有 Shell 边界,或明确记录有意替换。 + +G0 前,任何 Phase 1 生产 API 都不能冻结自己重复的 contract。 + +候选 type、SQLite schema、supervision primitive 与 ACP feasibility slice 降低了 G0 实现风险, +但缺少 canonical fixture、ADR sign-off、identity admission 与 recovery artifact,因此 G0 仍未通过。 + +### G1:Local Durable Gateway + +当前状态:**NOT ACCEPTED;只有局部 library foundation**。 + +退出 Gate 必须满足: + +- 本地认证 Unix socket API 与幂等 Task submission; +- 跨进程重启的持久 Task command/event/snapshot 行为; +- Task event 与 Outbox 原子 append; +- 可续租 runner lease 与显式 uncertain-side-effect 处理; +- 通用 Capability Broker,签发绑定 target、会过期且只允许单一 operation 的 permit; +- 通过 platform operator 确定性执行 typed operation; +- cosh-core lifecycle 只能通过 `AgentRuntimePort` 访问; +- cancellation、approval race、crash recovery 与 audit correlation 测试; +- handler、presenter 或 Agent bridge 都不能直接执行 OS action。 + +Reducer/store/supervisor slice 不提供 daemon、API、TaskCoordinator、runner lease/recovery loop、集成 +Runtime Port 或通用 production Capability gate,因此不能满足 G1。 + +### GM:Local ACP Runtime MVP + +当前状态:**NOT ACCEPTED;只有局部 library foundation**。 + +退出要求一个已安装 COSH entrypoint 通过已安装的 `codex-acp` 或 `claude-agent-acp`,运行且仅运行 +一个 canonical workspace、ACP connection/session 与 active bounded text prompt。Session Driver 必须在 +stdout 静默或 reader 阻塞时保持 cancel 独立;transport failure 必须 fail closed;local Permission +Proxy 只允许有关联的 `allow_once` 与 `reject_once` decision。至少一个真实 adapter 必须在同一个 +candidate revision 上通过 initialize、multi-chunk prompt、terminal result、独立 cancel、allow once +与 reject once。 + +Codex/Claude 原生 ACP、`npx` 或其他 package runner、network bootstrap、filesystem/terminal callback、 +load/resume、Web 与 Gateway daemon 都不属于本 MVP,也不能用来满足它。 + +### G2:ACP 与 Interactive Attachment + +当前状态:**NOT ACCEPTED;只有第一轮 ACP library slice**。 + +退出 Gate 必须满足: + +- 通过本地 stdio 完成 ACP v1 initialization 与 capability negotiation; +- 把 ACP baseline session 与 streaming 行为映射为 Runtime type; +- ACP permission、filesystem 和 terminal request 进入持久 approval 与 Capability Broker; +- incompatible protocol、missing capability、malformed stdout、child exit、cancellation + 与 session recovery conformance case; +- Shell attach/detach/replay,同时保持 PTY ownership 与 direct mode; +- Web/API cursored replay、approval、cancellation 与安全 output view; +- Outbox retry 与稳定 Delivery Receipt 语义; +- 证明 Task、Run、ACP session、Shell session、Request、Tool 与 Execution identity 各自独立。 + +## 实现验收必须提供的证据包 + +每个模块实现报告必须包括: + +1. 候选 branch 和完整 commit SHA; +2. 被评审 requirement row 与源码链接; +3. 精确 command、environment、test count 与结果; +4. 有版本 fixture 或已脱敏的 protocol transcript; +5. Negative、race 与 failure case,不能只有成功路径; +6. 未验证 provider、ECS、platform 或手工 UI 路径; +7. Rollback 或 compatibility 结果; +8. Security 或 wire-contract 决策的 reviewer sign-off。 + +证据不能包含凭证、原始 prompt、私有 Terminal output、host identifier 或不受限环境值。 + +## 跨模块验收场景 + +这些场景不能由单个 unit test 关闭。 + +| 场景 | 预期证据 | +| --- | --- | +| 重复钉钉/Web/CLI submission | 只产生一次 Task 状态效果并返回同一个 `TaskId` | +| Gateway 在 event commit 后崩溃 | 恢复 Task 与 Outbox,不重复副作用 | +| OS write 期间 runner lease 过期 | Execution 进入 uncertain 或 reconciliation,不能盲目 replay | +| 两个 Approval callback 竞争 | 一个 terminal decision 生效,两方都取得已提交状态 | +| cosh-core 在 turn 中退出 | 只产生一个 terminal Runtime event,并确定性 suspend/fail Task | +| ACP Agent 请求 Terminal execution | Broker decision 与 permit 先于 target execution,完整 ID 进入 audit | +| Shell 在 Approval 期间 detach | Task 继续 waiting,另一授权客户端无需拥有 PTY 即可处理审批 | +| Web delivery 不可用 | Task 按状态继续,Outbox 独立 retry delivery | +| Provider 网络不可用 | 显式 suspend 或按配置切换端侧模型,不降低 policy | +| 活跃 Attachment 期间 Gateway 重启 | Client 从 cursor replay,不把内存 UI state 当作持久事实 | + +## Scope-proportional 候选验证 + +实现 owner 与集成 owner 对共享工作树中的 Rust slice 运行 targeted package check,文档集成同时 +运行对应的双语与仓库文档检查: + +- 检查双语文件配对与语义一致; +- 验证相对 Markdown link; +- 运行 `git diff --check`; +- 检查 command 与实现声明是否符合基线和候选源码; +- 保留精确 command 与结果,不把 package evidence 提升为 full-system gate。 + +不宣称 full workspace test、workspace-wide Clippy、release build、ECS、provider 或手工 +Terminal gate。 + +### 已记录的定向实现证据 + +| 切片 | 已记录 command/result | +| --- | --- | +| Contracts | `cargo test --locked --package cosh-gateway-contracts`:6 个 integration test 通过;unit/doc-test target 通过。Package fmt、all-target Clippy、rustdoc 与 dependency-tree check 也通过。 | +| Gateway library integration | `cargo +1.88.0 test --locked --package cosh-gateway --no-fail-fast`:84 passed、0 failed。这是单 package suite,不是 workspace/full-system gate。 | +| Task reducer | Aggregate suite 包含 15 个 focused transition test,包括 unresolved/uncertain execution guard。 | +| SQLite storage | Storage suite 包含 15 个 focused test,包括 normal load/commit 的 snapshot replay verification。 | +| Runtime 与 ACP | Package suite 覆盖 private JSONL、ACP v1 codec/Bridge、固定 profile、有界 I/O/supervision 与可独立 cancel 的 Session Driver。Gateway all-target Clippy 与 package rustdoc 通过。 | +| Capability | `cargo +1.88.0 test --locked --package cosh-gateway capability --no-fail-fast`:12 passed、0 failed。只验证 in-memory decision/permit slice。 | + +### 规划文档证据 + +| 检查 | 结果 | +| --- | --- | +| 模块文档包 | PASS:每个模块都有中英文 `design` 与 `acceptance` 文档 | +| 仓库文档 lint | PASS:`bash scripts/docs-lint.sh` | +| 仓库 link 检查 | PASS:`python3 scripts/docs-link-check.py` | +| 完整 owned-document link 检查 | PASS:8 份总体/开发者指南文档中的全部 relative link 可解析 | +| Markdown 卫生 | PASS:`git diff --check` 与 owned-file 行尾空白检查 | +| 实现声明复核 | PASS:区分基线与候选声明;ACP codec/Bridge/profile/Driver foundation 与缺失的已安装 entrypoint、production governance、real-adapter 证据明确区分 | + +已记录的代码结果属于 scope-proportional package gate,不是 full workspace 或 live-system +validation。ECS validation、provider call 与手工 Terminal UX 未运行。 + +## 验收 Owner 与更新规则 + +Architecture Owner 维护本总报告。Module Owner 在产出实现证据的 PR 中更新详细报告。 +只有全部模块报告满足 exit criteria,并且本报告记录精确聚合候选 commit 后,阶段才能通过。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture.md new file mode 100644 index 0000000000..9575bc3afc --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture.md @@ -0,0 +1,407 @@ +# Cross-phase Architecture + +[中文版](architecture_zh.md) + +## Decision summary + +COSH becomes a local-first Agent OS gateway with four independent planes: + +1. channel and presentation adapters; +2. a durable Task Execution Plane; +3. replaceable Agent Runtime adapters; +4. governed OS capability and execution targets. + +The first deployment may place several modules in one process. Logical +ownership, typed ports, storage transactions, and security boundaries remain +separate even when process boundaries are collapsed. + +## Baseline evidence and gaps + +The baseline is a five-crate workspace. Its current architecture is documented +in the [developer guide](../../../../../docs/developer-guide/en/cosh-ng/architecture.md) +and [runtime contracts](../runtime-contracts.md). + +| Current capability | Reuse | Gap that this plan addresses | +| --- | --- | --- | +| `cosh-shell` owns PTY, input routing, cards, approvals, evidence, and the cosh-core child | Interactive client and foreground executor | No durable Task, multi-client attachment, or channel-neutral API | +| `AgentAdapter`, `AgentRunHandle`, and `AgentEvent` model a provider lifecycle | Runtime event normalization experience | Shell types and in-memory ownership are unsuitable as Gateway wire contracts | +| cosh-core JSONL negotiates its exact internal control protocol and streams Agent events | First `CoshCoreBridge` transport | Not ACP and not a public Gateway protocol | +| `SessionStore` persists model-visible conversation by workspace | Provider-session continuity | No Task, approval, delivery, execution lease, or Outbox state | +| `cosh-cli` and `cosh-platform` expose typed package, service, checkpoint, and audit operations | Deterministic OS operators | No single broker in front of every side effect | +| Unified audit events correlate bounded runtime metadata | Security and operations timeline | Task events and delivery state remain separate contracts | + +The baseline has no `cosh-gateway`, `TaskCoordinator`, `TaskStore`, +`CapabilityBroker`, ACP client, Web attachment, or channel adapter. Those items +must remain marked as planned until implementation evidence passes a module +acceptance report. + +## Candidate-worktree foundation + +The uncommitted candidate worktree based on the baseline adds two library +crates and several bounded implementation slices. Solid boxes below exist as +source; dashed edges remain integration work. + +```mermaid +flowchart LR + CT["cosh-gateway-contracts\nIDs + Task/Runtime/Capability types"] + RED["TaskAggregate\npure reducer"] + DB[("SQLite WAL\nevents + projection + receipts + Outbox")] + RS["RuntimeSupervisor\nprocess group + bounded I/O + reap"] + CJ["private COSH JSONL v1 codec"] + CAP["Capability Broker slice\nin-memory + targeted tests"] + API["Gateway daemon / API\nnot implemented"] + CCB["CoshCoreBridge\nnot implemented"] + ACP["ACP codec + bridge + profiles\npartial library slice"] + + CT --> RED + RED --> DB + CT --> CAP + CT -.->|future public mapping| CCB + API -.-> RED + CCB -.-> RS + CCB -.-> CJ + ACP --> RS +``` + +The contracts leaf validates bounded leaf strings/digests, +schema/envelope kinds, distinct IDs, Runtime bindings, Task events, and +Capability/Permit shapes. It does not yet bound every collection or aggregate +envelope. The reducer +enforces identity, consecutive revisions, active Run, approval/execution, and +terminal transition rules. The single-writer store uses checked SQLite schema +v1, WAL/FULL policy, private path checks, and one transaction for Task events, +projection, command receipt, and Outbox intents. The supervisor validates a +direct launch, clears inherited environment, bounds JSONL/stderr, owns the +process group, escalates shutdown, reaps, and emits one process terminal. + +This is not yet a runnable Gateway. There is no daemon entry point, ingress or +network API, coordinator/runner lease loop, public Runtime-event mapping, +restart recovery worker, Shell attachment, or Web/channel presentation. The +ACP codec/bridge and fixed installed-executable profiles exist as a library +slice with a bounded independently cancellable session driver, but there is no +installed entrypoint, production permission UI/evidence, or real-adapter +conformance evidence. +Capability code is package-exposed and passes targeted tests, +but remains partial because the store is in-memory and no target executes from +its claim. Existing Shell PTY/core ownership is +unchanged. + +## Target logical system view + +The following diagram is the target architecture, not the current process +topology: + +```mermaid +flowchart TB + subgraph Clients["Clients"] + DD["DingTalk / Feishu"] + WEB["Web / Web Shell"] + CLI["CLI / API"] + SH["cosh-shell"] + end + + subgraph Edge["Channel and Presentation"] + CA["ChannelAdapter"] + IP["IngressPort"] + PP["PresentationPort"] + end + + subgraph Tasks["Durable Task Execution Plane"] + ID["IdentityResolver"] + GA["Gateway API"] + TC["TaskCoordinator"] + TS[("TaskStore + TaskEventStore")] + AP["ApprovalService"] + PJ["Projection + Outbox"] + end + + subgraph Runtime["Agent Runtime Plane"] + AR["AgentRuntimePort"] + CB["CoshCoreBridge"] + AB["AcpClientBridge"] + SUP["RuntimeSupervisor"] + LM["LocalModelBridge"] + CORE["cosh-core"] + EA["External ACP Agents"] + end + + subgraph Governance["OS Governance and Execution"] + BR["CapabilityBroker"] + PE["Policy Engine"] + ET["ExecutionTargetPort"] + PTY["Interactive Shell Executor"] + OP["Typed Operators"] + EX["Skills / MCP / Workflows"] + AU["Audit / Checkpoint / Evidence refs"] + OS["GuestOS / ECS / Container"] + end + + DD --> CA + WEB --> CA + CLI --> CA + SH --> CA + CA --> IP --> ID --> GA --> TC + TC <--> TS + TC <--> AR + AR <--> CB <--> CORE + AR <--> AB <--> EA + CB -. "lifecycle" .-> SUP + AB -. "lifecycle" .-> SUP + SUP -. "process owner" .-> CORE + SUP -. "process owner" .-> EA + AR <--> LM + AR <--> BR + BR <--> PE + BR --> AP --> TC + TC -->|"committed resolution"| BR + BR <--> ET + ET --> PTY --> OS + ET --> OP --> OS + ET --> EX --> OS + BR --> AU + TC --> PJ --> PP --> CA +``` + +## Port ownership + +Every fan-in or fan-out point has one semantic owner. A box forwarding +unconstrained JSON is not an abstraction. + +| Boundary | Port | Canonical input | Canonical output | Owner | +| --- | --- | --- | --- | --- | +| Channels to Gateway | `IngressPort` | `IngressEnvelope` | `IngressAck` with `TaskId` | Gateway API | +| Channel assertion to OS grants | `IdentityResolver` | Source assertion and installation binding | `ActorContext` | Identity module | +| Task to Agent implementation | `AgentRuntimePort` | `AgentRunSpec` and runtime command | `AgentRuntimeEvent` | Runtime module | +| Agent intent to side effect | `CapabilityBrokerPort` | `CapabilityRequest` | deny, approval, or scoped permit | Capability module | +| Broker to a machine or shell | `ExecutionTargetPort` | permit-bound execution request | typed execution events | Target module | +| Task state to UI/channel | `PresentationPort` | `DeliveryIntent` | `DeliveryReceipt` | Projection and delivery | +| Task mutation and replay | `TaskEventStore` | expected-revision event append | ordered cursor and snapshot | Task module | + +Adapters preserve source metadata needed for reply routing, policy, audit, and +diagnosis, but downstream modules do not depend on a channel or Runtime's wire +types. + +The candidate worktree now uses the side-effect-free +`cosh-gateway-contracts` leaf crate, separate from the existing OS-facing +`cosh-types`. Its Rust types are a partial G0 implementation; canonical JSON +schemas/fixtures, ownership ADR acceptance, compatibility manifests, and +cross-adapter compile/fixture evidence remain required. This does not silently +change the standalone `cosh-shell` boundary: the first Shell Gateway client is +still unimplemented, and a direct internal crate dependency requires its own +boundary ADR. + +## Identity model + +IDs identify different lifecycles and are never aliases. + +| Identifier | Meaning | Authority | +| --- | --- | --- | +| `ChannelMessageId` | One inbound source message | Channel adapter | +| `ConversationRef` | Reply or thread location | Channel adapter | +| `ActorId` | Bound human, service, or installation identity | Identity resolver | +| `TaskId` | User-visible durable intent | Task Coordinator | +| `RunId` | One execution attempt for a Task | Task Coordinator | +| `AgentSessionId` | Runtime-specific conversation binding | Runtime bridge | +| `ShellSessionId` | One PTY ownership lifecycle | Shell host | +| `RequestId` | One correlated request/response exchange | Request initiator | +| `ToolUseId` | One Agent tool intent | Runtime bridge | +| `ExecutionId` | One governed side-effect attempt | Capability Broker | + +Required invariants include: + +- `TaskId != RunId != AgentSessionId != ShellSessionId`; +- an ACP `sessionId` maps only to `AgentSessionId`; +- every side-effect audit event carries `TaskId`, `RunId`, and `ExecutionId`; +- a channel retry reuses the ingress idempotency key and cannot create a + second Task state effect; +- a permit is bound to actor, target, operation digest, policy revision, + expiration, and `ExecutionId`. + +## Durable Task model + +`TaskCoordinator` is the sole writer of the Task aggregate. API handlers, +channel adapters, Agent bridges, runners, presenters, and approval callbacks +submit commands with an expected revision. + +```mermaid +stateDiagram-v2 + [*] --> Submitted + Submitted --> Queued: admitted + Queued --> Running: lease acquired + Running --> WaitingApproval: gated capability + WaitingApproval --> Running: resolution committed + WaitingApproval --> Suspended: approval expired + Running --> WaitingInput: elicitation + WaitingInput --> Running: input appended + Running --> Suspended: runtime or transport unavailable + Suspended --> Queued: retry requested + Running --> Succeeded: result committed + Running --> Failed: failure committed + Submitted --> Cancelled: cancel + Queued --> Cancelled: cancel + Running --> Cancelled: cancellation confirmed + WaitingApproval --> Cancelled: cancel + WaitingInput --> Cancelled: cancel +``` + +Task events are the durable control history and projection source. They do not +replace security audit events. Raw prompts, terminal output, model streams, +credentials, and environment values stay out of Task events; bounded evidence +or projections may be referenced through opaque IDs. + +Durability rules: + +- ingress and delivery are at-least-once with stable idempotency keys; +- Task event append and Outbox append share one transaction; +- the runner uses renewable leases, but lease expiry never proves an OS side + effect is safe to replay; +- each side effect has one `ExecutionId` and one broker permit; +- stream events carry source sequence or content identity for reconnect + deduplication; +- the first legal terminal approval transition wins; conflicting callbacks + return the already committed result. + +## Runtime model and ACP placement + +The Runtime Port hides provider process and wire differences: + +```text +inspect_capabilities(runtime_ref) +start(AgentRunSpec) -> AgentBinding +resume(AgentBinding, AgentRunSpec) +send_input(AgentBinding, TaskInput) +resolve_permission(AgentBinding, PermissionResolution) +cancel(AgentBinding, RequestId) +close(AgentBinding) +subscribe(AgentBinding, after_cursor) -> AgentRuntimeEvent stream +``` + +`CoshCoreBridge` owns protocol translation and the runtime binding for the +existing internal JSONL control protocol. `AcpClientBridge` acts as an ACP +Client over stdio. Both delegate spawn, process-group cancellation, stderr +bounds, timeout, and reap behavior to the shared `RuntimeSupervisor`; neither +writes Task storage or executes an OS action directly. + +ACP details: + +- protocol negotiation uses integer wire version `1`; +- SDK release version and ACP wire version are tracked separately; +- omitted capabilities are unsupported; +- baseline session methods are mapped into Runtime commands and events; +- ACP permission requests become durable approval or broker decisions; +- ACP filesystem and terminal requests enter the Capability Broker; +- ACP cancellation controls Runtime lifecycle; Task cancellation remains the + user-visible source of truth; +- remote clients use the COSH Gateway API because remote ACP transport is not + a Phase 0-2 dependency. + +The candidate worktree implements a bounded first slice of these ACP items: +official SDK 2.0.0 types, Rust 1.88, exact wire-v1 negotiation, supervised +stdio, one session, text prompt/update/stop, permission correlation, and +cancellation settlement. Durable Runtime/Task mapping and governed callbacks +remain incomplete. Private COSH JSONL control version `1` remains unrelated to +ACP wire version `1`. + +## Capability and approval model + +The Agent proposes intent; the Broker owns authorization. A request contains a +typed operation, resource/target, effect class, actor, Task and Run identity, +and a stable digest. The Broker produces one of: + +1. a denial with a stable reason code; +2. an `ApprovalRequest` persisted through the Task Coordinator; +3. a short-lived target-bound permit; +4. a typed execution result correlated by `ExecutionId`. + +Approval is durable Task state, not a card widget. A Shell or Web card is a +projection. Rendering a button, receiving a callback, or acknowledging a +message cannot authorize execution until the Task transition is committed. + +The worktree has neutral Capability/Approval/Permit contracts and a partial +package-exposed in-memory Broker/permit slice, but it does not yet prove the product invariant that every +enabled OS side effect passes through a consumed target-bound permit. Existing +legacy CLI, core, and Shell execution paths therefore remain outside the new +end-to-end governance claim. + +## Target process topology by phase + +| Phase | Required processes | Notes | +| --- | --- | --- | +| 0 | Existing binaries plus schema/fixture tooling | No production daemon is introduced | +| 1 | `cosh-gateway`, supervised `cosh-core`, local CLI client | A first implementation may keep Task, Broker, and projection modules in the gateway process | +| 2 | Phase 1 processes plus optional ACP Agent child and Web server endpoint | `cosh-shell` can attach to Gateway or retain direct local mode | + +`RuntimeSupervisor` is the single lifecycle owner for each Agent child. It +creates process groups, captures bounded stderr, propagates cancel, enforces +shutdown timeouts, and reaps the child. The corresponding bridge owns protocol +negotiation and connection/session state. A PID or dropped connection alone is +never a durable Task result. + +In the candidate worktree, `RuntimeSupervisor` exists only as a library owner +for a directly launched child. No Gateway daemon invokes it, no +`CoshCoreBridge` maps its observations, and `cosh-shell` still owns the current +interactive cosh-core compatibility process. + +## Dependency order + +```mermaid +flowchart LR + C["P0 Contracts"] --> G["P1 Gateway API"] + I["P0 Identity"] --> G + S["P0 Storage/Supervision ADR"] --> T["P1 Task Plane"] + C --> T + I --> T + C --> B["P1 Capability Broker"] + I --> B + T --> B + C --> CC["P1 CoshCore Bridge"] + T --> CC + B --> CC + C --> ACP["P2 ACP Bridge"] + T --> ACP + B --> ACP + T --> SH["P2 Shell Attachment"] + CC --> SH + T --> WEB["P2 Web/Presentation"] + SH --> WEB +``` + +Phase numbers describe delivery gates, not permission to create circular +dependencies. Leaf schema types must remain side-effect free; adapters depend +on ports, while domain modules do not import adapter implementations. + +## Failure ownership + +| Failure | Durable owner | Required behavior | +| --- | --- | --- | +| Duplicate webhook or CLI retry | Gateway and Task Coordinator | Return the existing `TaskId`; do not repeat state effects | +| Gateway restart | Task Plane | Rebuild from snapshot/events and reclaim only expired leases | +| cosh-core or ACP child exits | Runtime bridge | Emit one terminal Runtime event; Task decides suspend, retry, or fail | +| Provider network loss | Runtime bridge and Task policy | Suspend with bounded diagnostic metadata; optional local fallback requires explicit policy | +| Approval callback races | Task Coordinator | Commit only the first valid terminal decision | +| Delivery API outage | Outbox worker | Retry without changing Task execution state | +| Shell detaches during command | Shell attachment and Broker | Keep PTY ownership explicit; never silently transfer executor lease | +| Permit expires before execution | Broker | Reject execution and require a fresh decision | +| Uncertain OS side effect | Broker and Task Plane | Record uncertainty; require operator-safe reconciliation before retry | + +## Security and data boundaries + +- Channel authentication proves control of a channel account, not root, + workspace, target, or tool authority. +- The Gateway never trusts caller-supplied actor or target grants without an + installation binding. +- Task and delivery stores contain bounded structured data, not secrets or + raw model/terminal streams. +- Every OS side effect enters the Broker, including requests originating from + ACP `terminal/*`, ACP filesystem methods, Skills, MCP, or a local model. +- The existing unified audit contract remains distinct from Task event and + projection schemas. +- Endpoint and provider fallback policy is explicit; offline operation cannot + silently weaken approval or target restrictions. + +## Completion definition + +This architecture is implemented only when every module report has runtime +evidence at its exit criteria and the [overall acceptance report](acceptance-report.md) +is updated against the candidate commit. Document completeness alone satisfies +only the planning deliverable. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture_zh.md new file mode 100644 index 0000000000..284f288771 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/architecture_zh.md @@ -0,0 +1,366 @@ +# 跨阶段架构 + +[English](architecture.md) + +## 决策摘要 + +COSH 将演进为本地优先 Agent OS Gateway,并形成四个相互独立的平面: + +1. Channel 与 Presentation Adapter; +2. 持久 Task Execution Plane; +3. 可替换的 Agent Runtime Adapter; +4. 受治理的 OS Capability 与 Execution Target。 + +首个部署可以把多个模块放进同一个进程。即使合并进程,逻辑 ownership、typed +port、存储 transaction 与安全边界仍然必须分开。 + +## 基线证据与缺口 + +基线是五 crate workspace。当前架构记录在[开发者指南](../../../../../docs/developer-guide/zh/cosh-ng/architecture.md) +和 [Runtime Contracts](../runtime-contracts.md) 中。 + +| 当前能力 | 可复用部分 | 本规划处理的缺口 | +| --- | --- | --- | +| `cosh-shell` 拥有 PTY、输入路由、卡片、审批、证据和 cosh-core 子进程 | 交互客户端和前台 Executor | 没有持久 Task、多客户端 Attachment 或渠道无关 API | +| `AgentAdapter`、`AgentRunHandle` 和 `AgentEvent` 描述 provider 生命周期 | Runtime event 归一化经验 | Shell type 与内存 ownership 不适合作为 Gateway wire contract | +| cosh-core JSONL 协商内部 control protocol 并流式返回 Agent event | 首个 `CoshCoreBridge` transport | 不是 ACP,也不是公开 Gateway protocol | +| `SessionStore` 按 workspace 保存模型可见 conversation | Provider session 连续性 | 没有 Task、approval、delivery、execution lease 或 Outbox 状态 | +| `cosh-cli` 与 `cosh-platform` 提供类型化 package、service、checkpoint 和 audit 操作 | 确定性 OS operator | 所有副作用之前缺少统一 Broker | +| 统一 audit event 关联有界 Runtime metadata | 安全与运维时间线 | Task event 和 delivery state 仍需独立契约 | + +基线上不存在 `cosh-gateway`、`TaskCoordinator`、`TaskStore`、 +`CapabilityBroker`、ACP Client、Web Attachment 或 Channel Adapter。模块验收报告 +没有取得实现证据前,这些能力都必须标记为规划中。 + +## 候选工作树基础 + +基于该基线的未提交候选工作树增加两个 library crate 和若干有界实现切片。下图实线框表示 +已有源码,虚线表示仍待集成。 + +```mermaid +flowchart LR + CT["cosh-gateway-contracts\nID + Task/Runtime/Capability type"] + RED["TaskAggregate\npure reducer"] + DB[("SQLite WAL\nevent + projection + receipt + Outbox")] + RS["RuntimeSupervisor\nprocess group + bounded I/O + reap"] + CJ["private COSH JSONL v1 codec"] + CAP["Capability Broker slice\nin-memory + targeted test"] + API["Gateway daemon / API\n未实现"] + CCB["CoshCoreBridge\n未实现"] + ACP["ACP codec + Bridge + profile\n局部 library slice"] + + CT --> RED + RED --> DB + CT --> CAP + CT -.->|未来 public mapping| CCB + API -.-> RED + CCB -.-> RS + CCB -.-> CJ + ACP --> RS +``` + +Contracts leaf 校验有界 leaf string/digest、schema/envelope kind、不同 ID、Runtime binding、Task +event 与 Capability/Permit shape,但尚未限制每一个 collection 或 aggregate envelope。Reducer 执行 +identity、连续 revision、active Run、approval/execution 与 +terminal transition 规则。Single-writer store 使用经过检查的 SQLite schema v1、WAL/FULL policy、 +private path check,并在一个 transaction 中提交 Task event、projection、command receipt 与 Outbox +intent。Supervisor 校验 direct launch、清除 inherited environment、限制 JSONL/stderr、独占 process +group、升级 shutdown、reap,并只生成一次 process terminal。 + +这还不是可运行的 Gateway。当前没有 daemon entry point、ingress 或 network API、coordinator/runner +lease loop、public Runtime-event mapping、restart recovery worker、Shell Attachment 或 Web/channel +presentation。ACP codec/Bridge、固定的 installed-executable profile 与支持独立 cancel 的有界 +Session Driver 已形成 library slice,但仍没有已安装 entrypoint、production Permission UI/evidence +或 real-adapter conformance 证据。Capability code 已由 package 暴露并通过 targeted test,但 permit +store 仍在内存中,也没有 target 根据 claim 执行,因此仍是 partial。现有 Shell PTY/core ownership +没有改变。 + +## 目标逻辑系统视图 + +下图是目标架构,不是当前 process topology: + +```mermaid +flowchart TB + subgraph Clients["客户端"] + DD["钉钉 / 飞书"] + WEB["Web / Web Shell"] + CLI["CLI / API"] + SH["cosh-shell"] + end + + subgraph Edge["Channel 与 Presentation"] + CA["ChannelAdapter"] + IP["IngressPort"] + PP["PresentationPort"] + end + + subgraph Tasks["持久 Task Execution Plane"] + ID["IdentityResolver"] + GA["Gateway API"] + TC["TaskCoordinator"] + TS[("TaskStore + TaskEventStore")] + AP["ApprovalService"] + PJ["Projection + Outbox"] + end + + subgraph Runtime["Agent Runtime Plane"] + AR["AgentRuntimePort"] + CB["CoshCoreBridge"] + AB["AcpClientBridge"] + SUP["RuntimeSupervisor"] + LM["LocalModelBridge"] + CORE["cosh-core"] + EA["外部 ACP Agents"] + end + + subgraph Governance["OS 治理与执行"] + BR["CapabilityBroker"] + PE["Policy Engine"] + ET["ExecutionTargetPort"] + PTY["交互式 Shell Executor"] + OP["Typed Operators"] + EX["Skills / MCP / Workflows"] + AU["Audit / Checkpoint / Evidence refs"] + OS["GuestOS / ECS / Container"] + end + + DD --> CA + WEB --> CA + CLI --> CA + SH --> CA + CA --> IP --> ID --> GA --> TC + TC <--> TS + TC <--> AR + AR <--> CB <--> CORE + AR <--> AB <--> EA + CB -. "lifecycle" .-> SUP + AB -. "lifecycle" .-> SUP + SUP -. "process owner" .-> CORE + SUP -. "process owner" .-> EA + AR <--> LM + AR <--> BR + BR <--> PE + BR --> AP --> TC + TC -->|"已提交 resolution"| BR + BR <--> ET + ET --> PTY --> OS + ET --> OP --> OS + ET --> EX --> OS + BR --> AU + TC --> PJ --> PP --> CA +``` + +## Port ownership + +每个 fan-in 或 fan-out 位置只能有一个语义 owner。只转发任意 JSON 的组件不构成抽象。 + +| 边界 | Port | 统一输入 | 统一输出 | Owner | +| --- | --- | --- | --- | --- | +| Channel 到 Gateway | `IngressPort` | `IngressEnvelope` | 带 `TaskId` 的 `IngressAck` | Gateway API | +| Channel assertion 到 OS grant | `IdentityResolver` | 来源 assertion 和 installation binding | `ActorContext` | Identity module | +| Task 到 Agent 实现 | `AgentRuntimePort` | `AgentRunSpec` 和 Runtime command | `AgentRuntimeEvent` | Runtime module | +| Agent intent 到副作用 | `CapabilityBrokerPort` | `CapabilityRequest` | deny、approval 或 scoped permit | Capability module | +| Broker 到机器或 Shell | `ExecutionTargetPort` | 绑定 permit 的 execution request | typed execution event | Target module | +| Task state 到 UI/Channel | `PresentationPort` | `DeliveryIntent` | `DeliveryReceipt` | Projection 与 Delivery | +| Task mutation 与 replay | `TaskEventStore` | 带 expected revision 的 event append | 有序 cursor 与 snapshot | Task module | + +Adapter 保留回复路由、策略、审计和诊断需要的来源 metadata,但下游模块不能依赖 +Channel 或 Runtime 的 wire type。 + +候选工作树已经使用 side-effect-free leaf crate `cosh-gateway-contracts`,并与现有面向 OS 的 +`cosh-types` 分开。其 Rust type 是 G0 的局部实现;canonical JSON schema/fixture、ownership ADR +验收、compatibility manifest 与跨 Adapter compile/fixture evidence 仍是必需项。这不会静默改变 +`cosh-shell` standalone 边界。首个 Shell Gateway client 仍未实现;直接依赖内部 leaf crate 仍需单独 +通过边界 ADR。 + +## Identity model + +不同 ID 表达不同生命周期,不能互为别名。 + +| Identifier | 含义 | Authority | +| --- | --- | --- | +| `ChannelMessageId` | 一条来源消息 | Channel Adapter | +| `ConversationRef` | 回复或 thread 位置 | Channel Adapter | +| `ActorId` | 已绑定的人、服务或 installation 身份 | Identity Resolver | +| `TaskId` | 用户可见的持久 intent | Task Coordinator | +| `RunId` | Task 的一次执行尝试 | Task Coordinator | +| `AgentSessionId` | Runtime 专用 conversation binding | Runtime Bridge | +| `ShellSessionId` | 一次 PTY ownership 生命周期 | Shell Host | +| `RequestId` | 一次关联 request/response | Request 发起方 | +| `ToolUseId` | 一个 Agent tool intent | Runtime Bridge | +| `ExecutionId` | 一次受治理的副作用尝试 | Capability Broker | + +必须保持以下 invariant: + +- `TaskId != RunId != AgentSessionId != ShellSessionId`; +- ACP `sessionId` 只能映射为 `AgentSessionId`; +- 每个副作用 audit event 都携带 `TaskId`、`RunId` 和 `ExecutionId`; +- Channel retry 复用 ingress idempotency key,不能产生第二次 Task 状态效果; +- Permit 绑定 actor、target、operation digest、policy revision、过期时间和 `ExecutionId`。 + +## 持久 Task model + +`TaskCoordinator` 是 Task aggregate 的唯一 writer。API handler、Channel +Adapter、Agent Bridge、Runner、Presenter 和 Approval callback 只能提交带 expected +revision 的 command。 + +```mermaid +stateDiagram-v2 + [*] --> Submitted + Submitted --> Queued: admitted + Queued --> Running: 获得 lease + Running --> WaitingApproval: capability 受控 + WaitingApproval --> Running: resolution 已提交 + WaitingApproval --> Suspended: approval 过期 + Running --> WaitingInput: elicitation + WaitingInput --> Running: input 已追加 + Running --> Suspended: runtime 或 transport 不可用 + Suspended --> Queued: 请求 retry + Running --> Succeeded: result 已提交 + Running --> Failed: failure 已提交 + Submitted --> Cancelled: cancel + Queued --> Cancelled: cancel + Running --> Cancelled: cancellation 已确认 + WaitingApproval --> Cancelled: cancel + WaitingInput --> Cancelled: cancel +``` + +Task event 是持久控制历史和 projection 来源,不替代安全 audit event。原始 prompt、 +Terminal output、模型 stream、凭证和环境值不能进入 Task event;有界 evidence 或 +projection 只能通过 opaque ID 引用。 + +持久性规则如下: + +- Ingress 与 delivery 采用 at-least-once 和稳定 idempotency key; +- Task event append 与 Outbox append 共享一个 transaction; +- Runner 使用可续租 lease,但 lease 过期不能证明 OS 副作用可以安全重放; +- 每个副作用只有一个 `ExecutionId` 和一个 Broker permit; +- Stream event 携带 source sequence 或 content identity,用于 reconnect 去重; +- 第一个合法 terminal approval transition 生效,冲突 callback 返回已提交结果。 + +## Runtime model 与 ACP 位置 + +Runtime Port 隐藏 provider 进程和 wire 差异: + +```text +inspect_capabilities(runtime_ref) +start(AgentRunSpec) -> AgentBinding +resume(AgentBinding, AgentRunSpec) +send_input(AgentBinding, TaskInput) +resolve_permission(AgentBinding, PermissionResolution) +cancel(AgentBinding, RequestId) +close(AgentBinding) +subscribe(AgentBinding, after_cursor) -> AgentRuntimeEvent stream +``` + +`CoshCoreBridge` 拥有现有内部 JSONL control protocol 的转换与 Runtime binding。 +`AcpClientBridge` 通过 stdio 充当 ACP Client。两个 Bridge 都把 spawn、process-group +cancellation、stderr bound、timeout 与 reap 委托给共享 `RuntimeSupervisor`,并且都不能 +直接写 Task storage 或执行 OS action。 + +ACP 约束如下: + +- Protocol negotiation 使用整数 wire version `1`; +- SDK release version 与 ACP wire version 分开跟踪; +- 未声明的 capability 一律视为不支持; +- Baseline session method 映射为 Runtime command 与 event; +- ACP permission request 转成持久 approval 或 Broker decision; +- ACP filesystem 和 terminal request 进入 Capability Broker; +- ACP cancellation 控制 Runtime lifecycle,用户可见取消结果仍以 Task 为准; +- 远端 client 使用 COSH Gateway API,因为远端 ACP transport 不属于 Phase 0-2 依赖。 + +候选工作树已实现上述 ACP 能力的有界第一轮切片,包括官方 SDK 2.0.0 类型、Rust 1.88、 +exact wire-v1 negotiation、supervised stdio、单 session、text prompt/update/stop、 +permission correlation 与 cancellation settlement。持久 Runtime/Task mapping 与受治理 callback +仍未完成。Private COSH JSONL control version `1` 与 ACP wire version `1` 没有关联。 + +## Capability 与 Approval model + +Agent 只提出 intent,Broker 拥有授权。Request 包含 typed operation、resource/target、 +effect class、actor、Task/Run identity 和稳定 digest。Broker 只能产生以下结果: + +1. 带稳定 reason code 的 denial; +2. 通过 Task Coordinator 持久化的 `ApprovalRequest`; +3. 短生命周期、绑定 target 的 permit; +4. 通过 `ExecutionId` 关联的 typed execution result。 + +Approval 是持久 Task 状态,不是 card widget。Shell 或 Web card 只是 projection。 +在 Task transition 提交前,渲染按钮、收到 callback 或确认消息都不能授权执行。 + +工作树已有中立 Capability/Approval/Permit contract 与 package-exposed in-memory Broker/permit slice, +但尚未证明每条已启用 OS +副作用都通过已消费的 target-bound permit。现有 legacy CLI、core 与 Shell execution path 因此不属于 +新的端到端 governance 声明。 + +## 各阶段目标进程拓扑 + +| 阶段 | 必需进程 | 说明 | +| --- | --- | --- | +| 0 | 现有 binary 加 schema/fixture 工具 | 不引入生产 daemon | +| 1 | `cosh-gateway`、受监督的 `cosh-core`、本地 CLI client | 首版可以把 Task、Broker 与 projection 模块放在 Gateway 进程中 | +| 2 | Phase 1 进程,加可选 ACP Agent 子进程和 Web server endpoint | `cosh-shell` 可接入 Gateway,也保留 direct local mode | + +`RuntimeSupervisor` 是每个 Agent 子进程唯一的 lifecycle owner。它创建 process group、 +收集有界 stderr、传播 cancel、执行 shutdown timeout 并回收子进程;对应 Bridge 拥有 +protocol negotiation 与 connection/session state。PID 或连接断开本身不能成为持久 Task result。 + +候选工作树中的 `RuntimeSupervisor` 只作为直接启动 child 的 library owner 存在。没有 Gateway daemon +调用它,没有 `CoshCoreBridge` 映射其 observation,`cosh-shell` 仍然拥有当前 interactive cosh-core +compatibility process。 + +## 依赖顺序 + +```mermaid +flowchart LR + C["P0 Contracts"] --> G["P1 Gateway API"] + I["P0 Identity"] --> G + S["P0 Storage/Supervision ADR"] --> T["P1 Task Plane"] + C --> T + I --> T + C --> B["P1 Capability Broker"] + I --> B + T --> B + C --> CC["P1 CoshCore Bridge"] + T --> CC + B --> CC + C --> ACP["P2 ACP Bridge"] + T --> ACP + B --> ACP + T --> SH["P2 Shell Attachment"] + CC --> SH + T --> WEB["P2 Web/Presentation"] + SH --> WEB +``` + +Phase 编号描述交付 Gate,不允许创建循环依赖。Leaf schema type 必须保持无副作用; +Adapter 依赖 Port,Domain module 不 import Adapter 实现。 + +## 故障 Ownership + +| 故障 | 持久 Owner | 必需行为 | +| --- | --- | --- | +| 重复 webhook 或 CLI retry | Gateway 与 Task Coordinator | 返回现有 `TaskId`,不重复状态效果 | +| Gateway 重启 | Task Plane | 从 snapshot/event 恢复,只接管过期 lease | +| cosh-core 或 ACP 子进程退出 | Runtime Bridge | 只发一个 terminal Runtime event;由 Task 决定 suspend、retry 或 fail | +| Provider 网络中断 | Runtime Bridge 与 Task policy | 携带有界诊断 metadata 后 suspend;切换端侧模型必须有显式 policy | +| Approval callback 竞争 | Task Coordinator | 只提交第一个合法 terminal decision | +| Delivery API 不可用 | Outbox worker | 重试但不改变 Task execution state | +| Shell 在命令期间 detach | Shell Attachment 与 Broker | PTY ownership 保持显式;不能静默转移 executor lease | +| Permit 在执行前过期 | Broker | 拒绝执行并要求重新决策 | +| OS 副作用结果不确定 | Broker 与 Task Plane | 记录 uncertainty,完成安全 reconciliation 后才能 retry | + +## 安全与数据边界 + +- Channel authentication 只能证明调用方控制某个 Channel account,不能证明其拥有 + root、workspace、target 或 tool 权限。 +- Gateway 不接受未经 installation binding 验证的 caller actor 或 target grant。 +- Task 与 delivery store 只保存有界结构化数据,不保存 secret、原始模型或 Terminal stream。 +- 所有 OS 副作用都进入 Broker,包括来自 ACP `terminal/*`、ACP filesystem method、 + Skills、MCP 或端侧模型的请求。 +- 现有统一 audit contract 与 Task event、projection schema 保持独立。 +- Endpoint 与 provider fallback policy 必须显式;离线运行不能静默降低 approval 或 target 限制。 + +## 完成定义 + +只有每个模块报告都在候选 commit 上取得满足 exit criteria 的 Runtime 证据,并更新 +[总体验收报告](acceptance-report_zh.md),这套架构才能标记为已实现。文档完整只代表规划交付完成。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance.md new file mode 100644 index 0000000000..d5cae4ee39 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance.md @@ -0,0 +1,152 @@ +# Phase 0 Identity and Correlation Acceptance Report + +[中文版](acceptance_zh.md) | [Design](design.md) | +[Planning set](../../README.md) + +## Baseline result + +**The typed leaf identity slice is accepted; G0 exit is not.** The +implementation worktree is based on +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. + +The new contract crate adds distinct validated internal IDs, `Correlation`, +bounded `ExternalRef`, actor and target references, and a Runtime binding that +includes its instance and generation. Gateway storage now persists a Task +owner, typed event identities, and actor-scoped idempotency receipts, while the +Broker validates complete authoritative Actor provenance. It does not add an +actor registry, durable external-reference mapping, full Runtime/capability +relations, or an active Runtime-generation admission fence. + +## Evidence reviewed + +| Source/symbol | Verified fact | +| --- | --- | +| [`ProviderSessionId::parse`](../../../../../crates/cosh-core/src/session.rs#L28) | Rejects non-canonical provider-session UUIDs before path construction | +| [`PersistedSession.workspace_scope`](../../../../../crates/cosh-core/src/session.rs#L83) | Provider history is bound to a canonical workspace | +| [`AuditIdentity`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) | Existing audit correlation uses optional string fields | +| [`ShellCommandAuditIdentity`](../../../../../crates/cosh-shell/src/types/mod.rs#L55) | Shell handoff carries Run, request, and tool references separately | +| [`ProviderToolKey`](../../../../../crates/cosh-shell/src/runtime/provider_tool_state.rs#L236) | Tool state scopes tool ID by Run in memory | +| [`RunCommand`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service/command.rs#L21) | Core service carries Run and session scope but no durable binding generation | +| [`ids.rs`](../../../../../crates/cosh-gateway-contracts/src/ids.rs) | Sixteen prefixed internal ID newtypes share canonical generation, parsing, serde, and cross-type rejection | +| [`common.rs`](../../../../../crates/cosh-gateway-contracts/src/common.rs) | `Correlation`, `ActorRef`, `RuntimeBindingRef`, digests, and bounded values are typed | +| [`external.rs`](../../../../../crates/cosh-gateway-contracts/src/external.rs) | External namespace, authority, scope digest, and bounded opaque value are represented separately | +| [`task_store.rs`](../../../../../crates/cosh-gateway/src/storage/task_store.rs) | Task owner, events, projections, and actor-scoped key plus payload-digest receipts are committed transactionally; replay/conflict and actor-substitution tests exist | +| [`capability/broker.rs`](../../../../../crates/cosh-gateway/src/capability/broker.rs) | Request admission compares the complete authoritative `ActorRef`, including ID, issuer, kind, and assurance, with no Task-storage write | + +Targeted tests exercised ID canonicalization, cross-type parsing, serde +validation, envelope schema matching, and size limits. No provider, ECS, or +host-mutation validation was needed for this side-effect-free crate. + +## Acceptance matrix + +| ID | Requirement | Baseline | Evidence required to pass | +| --- | --- | --- | --- | +| IC-01 | All internal lifecycle IDs are distinct validated newtypes | Pass for leaf types | Constructor, canonical serde, and cross-parse unit tests pass; property fixtures remain for G0 | +| IC-02 | Task, Run, Agent Session, Runtime binding, Approval, Permit, Execution, and Delivery parents are enforced | Partial | Runtime binding, capability request, and permit carry parents; database foreign-key and domain-constructor tests remain | +| IC-03 | Actor derives from authenticated issuer/subject, never request payload | Partial | Broker rejects complete Actor provenance substitution against its authoritative binding; authenticated ingress/IdentityResolver tests remain | +| IC-04 | Channel references include adapter, authority, conversation, and message scope | Partial | Kind, authority, scope digest, and opaque value are required; cross-tenant collision and retry fixtures remain | +| IC-05 | Provider and ACP IDs remain opaque external references | Partial | External kinds and bounded opaque values are typed; bridge tests with arbitrary, colliding, and non-UUID values remain | +| IC-06 | Runtime generation fences stale child output | Partial | Runtime binding carries instance and generation only; no active admission fence exists, and crash/restart delayed-event tests remain | +| IC-07 | Tool use and OS Execution identities are never conflated | Partial | Distinct `ToolUseId` and `ExecutionId` newtypes pass cross-parsing; multi-execution fixtures and durable constraints remain | +| IC-08 | Idempotency key reuse checks scoped payload digest | Partial | SQLite tests replay the same actor/key/digest and reject another digest or actor; authenticated ingress scope and channel fixtures remain | +| IC-09 | External identity values are bounded and redacted in diagnostics | Partial | Bounded construction and deserialization exist; injection, encryption/digest, and log tests remain | +| IC-10 | Legacy provider-session and Shell identities migrate without guessing Task identity | Design only | Dual-mode migration fixtures and explicit gap output | +| IC-11 | Audit schema change for new fields is reviewed explicitly | Missing | Accepted audit compatibility decision and reader tests | +| IC-12 | English/Chinese documents are equivalent and links resolve | Ready after doc validation | Recorded documentation checks | + +## Required fixtures and artifacts + +```text +fixtures/identity/v1/ + internal-ids.json + correlation-complete.json + external-channel-ref.json + external-provider-session-ref.json + external-acp-refs.json + runtime-binding-generation.json + approval-permit-execution-chain.json + legacy-correlation-gap.json + malformed/ + wrong-prefix.json + noncanonical-id.json + cross-tenant-message.json + cross-task-run.json + stale-runtime-generation.json + oversized-external-value.json + actor-substitution.json +``` + +Required implementation artifacts also include: + +- an ID registry documenting prefix, scope, allocator, lifetime, and parent; +- database DDL with foreign keys and scoped unique indexes; +- a data-classification record for raw, encrypted, digested, and loggable + external reference fields; +- audit compatibility fixtures for readers before and after the change; +- an exact mapping table for cosh-core, Shell, and ACP IDs. + +The typed source exists; the listed versioned fixtures and durable artifacts +remain pending. + +## Required validation commands + +Final G0 acceptance must include these equivalent targeted commands: + +```bash +cargo test --package cosh-gateway-contracts identity +cargo test --package cosh-gateway identity_resolver +cargo test --package cosh-gateway runtime_fencing +cargo test --package cosh-gateway --test identity_storage +cargo test --package cosh-shell --test protocol +``` + +Targeted leaf-crate validation recorded for this slice: + +```text +cargo fmt --package cosh-gateway-contracts -- --check +cargo test --locked --package cosh-gateway-contracts +cargo clippy --locked --package cosh-gateway-contracts --all-targets -- -D warnings +cargo doc --locked --package cosh-gateway-contracts --no-deps +cargo tree --locked --package cosh-gateway-contracts --edges normal +result: 6 integration tests passed; unit and doc-test targets passed +dependency result: serde, thiserror, and uuid only +``` + +Property-test seeds and test counts for storage, fencing, and ingress must be +retained when those remaining commands are added. + +## Missing implementation + +- Task owner/event/storage identity relations and actor-scoped receipts exist; + full actor registry, external-reference, Runtime-binding, Approval, Permit, + Execution, and Delivery relations or foreign keys remain absent. +- No actor mapping registry or authenticated identity resolver. +- No Runtime event-admission fence; only the generation-bearing binding type exists. +- No channel identity or scoped ingress idempotency. +- No durable ACP Connection, Session, Request, Message, Tool Call, or Terminal mapping; + the corresponding external kinds exist only as pure references. +- No accepted audit evolution for new correlation fields. + +## Exit criteria + +G0 identity acceptance requires: + +1. IC-01 through IC-12 pass on one recorded implementation commit. +2. Prefix and UUID representation are frozen by ADR. +3. All parent relations are enforced in constructors and storage. +4. Runtime restart tests prove stale output cannot mutate a Task. +5. Channel replay and actor-substitution tests fail closed. +6. ACP fixtures prove external values remain opaque and connection-scoped. +7. Logs, errors, and audit output contain no raw sensitive external identity. +8. Legacy records surface correlation gaps rather than invented identities. + +## Validation recorded for this slice + +- Reciprocal English/Chinese links are present. +- Tables, code blocks, ID names, and fixture lists are semantically aligned. +- Relative source links were checked from this directory. +- Markdown whitespace and diff hygiene were checked. +- Targeted formatting, package tests, Clippy, rustdoc, and dependency audit + passed with the commands recorded above. +- ECS, provider, and host-mutation validation was intentionally skipped because + this crate has no I/O or host behavior. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance_zh.md new file mode 100644 index 0000000000..29a728eb5d --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/acceptance_zh.md @@ -0,0 +1,149 @@ +# Phase 0 Identity and Correlation 验收报告 + +[English](acceptance.md) | [设计](design_zh.md) | +[规划集](../../README_zh.md) + +## 基线结论 + +**Typed leaf identity 切片已通过,G0 退出条件尚未达到。** 实现 worktree 基于 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。 + +新的 contract crate 增加不同的 validated internal ID、`Correlation`、bounded +`ExternalRef`、actor/target reference,以及包含 instance 与 generation 的 Runtime +binding。Gateway storage 现在会持久化 Task owner、typed event identity 与 +actor-scoped idempotency receipt,Broker 会校验完整 authoritative Actor provenance。 +它仍不包含 actor registry、durable external-reference mapping、完整 Runtime/capability +relation 或 active Runtime-generation admission fence。 + +## 已审计证据 + +| 来源/符号 | 已核实事实 | +| --- | --- | +| [`ProviderSessionId::parse`](../../../../../crates/cosh-core/src/session.rs#L28) | 构造 path 前拒绝 non-canonical provider-session UUID | +| [`PersistedSession.workspace_scope`](../../../../../crates/cosh-core/src/session.rs#L83) | Provider history 绑定 canonical workspace | +| [`AuditIdentity`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) | 当前 audit correlation 使用 optional string field | +| [`ShellCommandAuditIdentity`](../../../../../crates/cosh-shell/src/types/mod.rs#L55) | Shell handoff 分开携带 Run、request 与 tool reference | +| [`ProviderToolKey`](../../../../../crates/cosh-shell/src/runtime/provider_tool_state.rs#L236) | Tool state 在内存中通过 Run 限定 tool ID scope | +| [`RunCommand`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service/command.rs#L21) | Core service 携带 Run 与 session scope,但没有 durable binding generation | +| [`ids.rs`](../../../../../crates/cosh-gateway-contracts/src/ids.rs) | 16 个 prefixed internal ID newtype 共享 canonical generation、parsing、serde 与 cross-type rejection | +| [`common.rs`](../../../../../crates/cosh-gateway-contracts/src/common.rs) | `Correlation`、`ActorRef`、`RuntimeBindingRef`、digest 与 bounded value 已类型化 | +| [`external.rs`](../../../../../crates/cosh-gateway-contracts/src/external.rs) | External namespace、authority、scope digest 与 bounded opaque value 分离表示 | +| [`task_store.rs`](../../../../../crates/cosh-gateway/src/storage/task_store.rs) | Task owner、event、projection 与 actor-scoped key 加 payload-digest receipt 在一个 transaction 中提交;包含 replay/conflict 与 actor-substitution test | +| [`capability/broker.rs`](../../../../../crates/cosh-gateway/src/capability/broker.rs) | Request admission 比较完整 authoritative `ActorRef`,包括 ID、issuer、kind 与 assurance,并且不写 Task storage | + +Targeted test 覆盖 ID canonicalization、cross-type parsing、serde validation、 +envelope schema matching 与 size limit。Side-effect-free crate 不需要 provider、 +ECS 或 host-mutation validation。 + +## 验收矩阵 + +| ID | 要求 | 基线 | 通过所需证据 | +| --- | --- | --- | --- | +| IC-01 | 所有 internal lifecycle ID 都是不同的 validated newtype | Leaf type 通过 | Constructor、canonical serde 与 cross-parse unit test 通过;G0 仍需 property fixture | +| IC-02 | Task、Run、Agent Session、Runtime binding、Approval、Permit、Execution、Delivery parent 被强制执行 | 部分 | Runtime binding、capability request 与 permit 携带 parent;database foreign-key 与 domain-constructor test 尚未完成 | +| IC-03 | Actor 来自 authenticated issuer/subject,不来自 request payload | 部分 | Broker 会根据 authoritative binding 拒绝完整 Actor provenance substitution;authenticated ingress/IdentityResolver test 尚未完成 | +| IC-04 | Channel reference 包含 adapter、authority、conversation 与 message scope | 部分 | Kind、authority、scope digest 与 opaque value 为必填;cross-tenant collision 与 retry fixture 尚未完成 | +| IC-05 | Provider 与 ACP ID 保持 opaque external reference | 部分 | External kind 与 bounded opaque value 已类型化;使用 arbitrary、colliding、non-UUID value 的 bridge test 尚未完成 | +| IC-06 | Runtime generation fence 能拒绝 stale child output | 部分 | Runtime binding 目前只携带 instance 与 generation;active admission fence 不存在,crash/restart delayed-event test 尚未完成 | +| IC-07 | Tool use 与 OS Execution identity 不混用 | 部分 | 不同 `ToolUseId` 与 `ExecutionId` newtype 通过 cross-parsing;multi-execution fixture 与 durable constraint 尚未完成 | +| IC-08 | Idempotency key reuse 校验 scoped payload digest | 部分 | SQLite test replay 同 actor/key/digest,并拒绝 another digest 或 actor;authenticated ingress scope 与 channel fixture 尚未完成 | +| IC-09 | External identity value bounded,并在 diagnostics 中 redacted | 部分 | Bounded construction 与 deserialization 已实现;injection、encryption/digest 与 log test 尚未完成 | +| IC-10 | Legacy provider-session 与 Shell identity 迁移时不猜测 Task identity | 仅设计 | Dual-mode migration fixture 与显式 gap output | +| IC-11 | 新字段的 audit schema change 经过显式评审 | 缺失 | Accepted audit compatibility decision 与 reader test | +| IC-12 | 中英文文档等价且链接可用 | 文档检查后就绪 | 已记录的 documentation check | + +## 必要 Fixture 与 Artifact + +```text +fixtures/identity/v1/ + internal-ids.json + correlation-complete.json + external-channel-ref.json + external-provider-session-ref.json + external-acp-refs.json + runtime-binding-generation.json + approval-permit-execution-chain.json + legacy-correlation-gap.json + malformed/ + wrong-prefix.json + noncanonical-id.json + cross-tenant-message.json + cross-task-run.json + stale-runtime-generation.json + oversized-external-value.json + actor-substitution.json +``` + +必要实现产物还包括: + +- 记录 prefix、scope、allocator、lifetime 与 parent 的 ID registry; +- 包含 foreign key 与 scoped unique index 的 database DDL; +- 记录 external reference field 分别使用 raw、encrypted、digested 或 loggable + 形式的 data-classification 文档; +- 变更前后 reader 的 audit compatibility fixture; +- cosh-core、Shell 与 ACP ID 的准确 mapping table。 + +Typed source 已存在;上述 versioned fixture 与 durable artifact 尚未完成。 + +## 必要验证命令 + +最终 G0 验收必须包含下列等价 targeted command: + +```bash +cargo test --package cosh-gateway-contracts identity +cargo test --package cosh-gateway identity_resolver +cargo test --package cosh-gateway runtime_fencing +cargo test --package cosh-gateway --test identity_storage +cargo test --package cosh-shell --test protocol +``` + +本切片记录的 targeted leaf-crate validation: + +```text +cargo fmt --package cosh-gateway-contracts -- --check +cargo test --locked --package cosh-gateway-contracts +cargo clippy --locked --package cosh-gateway-contracts --all-targets -- -D warnings +cargo doc --locked --package cosh-gateway-contracts --no-deps +cargo tree --locked --package cosh-gateway-contracts --edges normal +result: 6 integration tests passed;unit 与 doc-test target passed +dependency result:仅 serde、thiserror 与 uuid +``` + +Storage、fencing 与 ingress test 添加后,报告必须保留 property-test seed 与 +test count。 + +## 未实现项 + +- Task owner/event/storage identity relation 与 actor-scoped receipt 已存在;完整 actor + registry、external-reference、Runtime-binding、Approval、Permit、Execution 与 Delivery + relation 或 foreign key 仍缺失。 +- 没有 actor mapping registry 或 authenticated identity resolver。 +- 没有 Runtime event-admission fence;只有携带 generation 的 binding type。 +- 没有 channel identity 或 scoped ingress idempotency。 +- 没有 durable ACP Connection、Session、Request、Message、Tool Call 或 Terminal mapping; + 对应 external kind 只存在于 pure reference 中。 +- 没有针对新 correlation field 的 accepted audit evolution。 + +## Exit Criteria + +G0 identity acceptance 要求: + +1. IC-01 至 IC-12 在一个记录准确的 implementation commit 上通过。 +2. Prefix 与 UUID representation 已通过 ADR 冻结。 +3. 所有 parent relation 都在 constructor 与 storage 中强制执行。 +4. Runtime restart test 证明 stale output 不能修改 Task。 +5. Channel replay 与 actor-substitution test fail closed。 +6. ACP fixture 证明 external value 保持 opaque 与 connection-scoped。 +7. Log、error 与 audit output 不包含 raw sensitive external identity。 +8. Legacy record 显示 correlation gap,不伪造 identity。 + +## 本切片的验证记录 + +- 已提供中英文 reciprocal link。 +- Table、code block、ID name 与 fixture list 语义一致。 +- 已从当前目录检查相对源码链接。 +- 已检查 Markdown whitespace 与 diff hygiene。 +- 上述 targeted formatting、package test、Clippy、rustdoc 与 dependency audit + 已通过。 +- 由于该 crate 没有 I/O 或 host behavior,有意跳过 ECS、provider 与 host-mutation + validation。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design.md new file mode 100644 index 0000000000..b662694c45 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design.md @@ -0,0 +1,331 @@ +# Phase 0 Identity and Correlation Design + +[中文版](design_zh.md) | [Acceptance report](acceptance.md) | +[Planning set](../../README.md) + +## Status and decision + +- Baseline: `up/main` at `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Status: typed identity foundation implemented; G0 storage/admission exit remains open + +COSH must assign its own typed lifecycle identities and preserve channel, +Shell, cosh-core, and ACP identifiers as scoped external references. Identity +equality is valid only for the same type and scope. In particular: + +```text +TaskId != RunId != AgentSessionId != ShellSessionId +RequestId != ToolUseId != ExecutionId != ApprovalId +``` + +An external session or message identifier can locate a binding, but it can +never authorize an actor, select an OS target, or become a Task identifier. + +## Goals + +- Define canonical internal IDs, external references, ownership, and scope. +- Correlate one user intent across channel admission, Task events, Agent + Runtime, approvals, OS execution, audit, and presentation delivery. +- Make duplicate delivery, stale Runtime output, and cross-task substitution + detectable before side effects. +- Support actor resolution for local users, automation, and future DingTalk or + Feishu adapters without storing channel credentials in domain events. +- Extend the existing audit vocabulary without silently changing its v1 wire + contract. + +## Non-goals + +- Designing a full IAM, organization directory, OAuth flow, or channel login. +- Treating Linux UID alone as a globally stable human identity. +- Exposing internal identifiers as secrets. IDs are unguessable correlation + handles, not authorization tokens. +- Reusing provider, ACP, JSON-RPC, or Shell IDs because their text happens to + match. +- Migrating existing provider session filenames or audit events in Phase 0. + +## Current-source evidence + +| Evidence | Baseline fact | Gap | +| --- | --- | --- | +| [`ProviderSessionId`](../../../../../crates/cosh-core/src/session.rs#L23) | Persistence accepts only a canonical lowercase UUID and scopes it to a canonical workspace | It identifies provider history only, not a Task or actor | +| [`AuditIdentity`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) | Audit events can carry installation, Shell session, provider session, Run, turn, request, tool, and command strings | Fields are optional strings and do not include Task, Approval, Execution, Delivery, actor, target, or Runtime generation | +| [`ShellEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L69) | Shell session and command identity are independent from provider audit identity | No durable Gateway binding exists | +| [`AgentRequest`](../../../../../crates/cosh-shell/src/types/mod.rs#L303) and [`AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L402) | `AgentRequest.id` flows as a string Run ID through Shell events | Type and generation fencing are missing | +| [`ProviderToolKey`](../../../../../crates/cosh-shell/src/runtime/provider_tool_state.rs#L236) | In-memory tool correlation already requires `(run_id, tool_id)` | The scope disappears after Shell exit and is not a durable execution identity | +| [`RunCommand`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service/command.rs#L21) | Persistent core service carries Run ID and separate session scope | The process binding has no durable owner/generation record | + +The implementation worktree adds the canonical +[`ids`](../../../../../crates/cosh-gateway-contracts/src/ids.rs), +[`common`](../../../../../crates/cosh-gateway-contracts/src/common.rs), and +[`external`](../../../../../crates/cosh-gateway-contracts/src/external.rs) +modules. A durable external-reference registry, actor resolver, and storage +constraints remain outside this leaf-contract slice. + +## Ownership + +| Owner | Responsibility | +| --- | --- | +| `cosh-gateway-contracts` leaf crate | ID newtypes, `Correlation`, `ExternalRef`, constructors' validation contract, and serializable scope types | +| Gateway `IdentityResolver` | Authenticate ingress, map issuer/subject to `ActorId`, and emit provenance | +| Task Coordinator | Allocate Task, Run, Approval, Execution, Delivery, and command Message IDs; enforce parent-child invariants | +| Runtime Supervisor | Allocate Runtime instance/generation and Connection IDs; register Agent Session bindings | +| Runtime bridges | Preserve provider and ACP values as opaque scoped references; never parse semantic meaning from them | +| Capability Broker | Bind actor, Task, Run, target, operation, permit, Approval, and Execution IDs | +| Audit projection | Add new optional correlation fields in a separately reviewed schema revision or map them through references | +| Channel adapters | Construct bounded issuer-specific external references and idempotency material; never assign internal ownership | + +The G0 ownership ADR for `cosh-gateway-contracts` also governs these newtypes. +`cosh-shell` keeps its standalone boundary and mirrors Gateway wire IDs through +canonical fixtures unless a later ADR permits a direct dependency. + +## Identity taxonomy + +### COSH-assigned internal identities + +| Type | Scope and lifetime | Parent/invariant | +| --- | --- | --- | +| `InstallationId` | One local Gateway installation; durable | Never derived from hostname or machine-id alone | +| `ActorId` | Principal known to this installation; durable | Maps from authenticated `(issuer, subject)` | +| `TaskId` | Durable user intent | Immutable owner and target policy context | +| `RunId` | One attempted Runtime turn or workflow run | Exactly one `TaskId`; retries get a new Run | +| `AgentSessionId` | COSH logical Agent conversation binding | One Task context; maps to provider/ACP external sessions without adopting their IDs | +| `RuntimeInstanceId` | One supervised child process | One launch specification and generation sequence | +| `RuntimeBindingId` | Binding between Run and external Agent Session | One Task, Run, Runtime instance, generation, and external session ref | +| `ApprovalId` | One durable decision request | One Task, Run, request digest, and policy revision | +| `PermitId` | One Broker authorization result | Bound to Approval if required, target, operation digest, expiry | +| `ExecutionId` | One attempted side effect | One permit; reused only for an executor's defined idempotent replay | +| `DeliveryId` | One Outbox delivery to one sink | One event and destination; attempts are separate counters | +| `MessageId` | One COSH command/event envelope | Globally unique within installation | + +Internal IDs use a short type prefix plus canonical lowercase hyphenated UUIDv4 +text, for example `tsk_` and `run_`. The allocator matches the +workspace's centralized `uuid` feature set and Rust 1.88 baseline. Durable +ordering uses Task revision or database sequence, never UUID ordering. A future +UUIDv7 allocator may retain the same prefixed text contract, but requires an +explicit compatibility decision. + +### Scoped external identities + +| Type | Required scope | Rule | +| --- | --- | --- | +| `ChannelConversationRef` | adapter + authority/tenant + conversation | Opaque and bounded; not an actor | +| `ChannelMessageRef` | conversation ref + message value | Supplies ingress deduplication material | +| `ShellSessionRef` | installation + Shell process/session | Never resumes provider context by itself | +| `ShellCommandRef` | Shell session + command ID | Identifies PTY evidence only | +| `ProviderSessionRef` | Runtime kind + workspace + provider session value | May map to current `ProviderSessionId`; never a Task ID | +| `AcpConnectionRef` | Runtime instance + generation | Allocated locally for stdio connection correlation | +| `AcpSessionRef` | ACP connection + opaque Agent session ID | May be reused across Runs only through an explicit binding | +| `AcpRequestRef` | ACP connection + JSON-RPC ID | JSON-RPC number and string forms remain distinct wire values | +| `AcpMessageRef` | ACP session + opaque optional message ID | Missing message ID requires local chunk sequence, not invented Agent identity | +| `AcpToolCallRef` | ACP session + opaque tool call ID | Maps to one internal tool observation, not directly to Execution | +| `TerminalRef` | Runtime binding + ACP terminal ID | Valid only while terminal ownership record exists | + +External values are stored separately from their scope. No code concatenates +unescaped strings to invent a composite primary key. + +## Typed schema + +The committed source implements the following shapes. The abbreviated view +below omits helper methods and validation details. + +```rust +struct Correlation { + installation_id: InstallationId, + actor_id: Option, + task_id: Option, + run_id: Option, + agent_session_id: Option, + runtime_binding_id: Option, + approval_id: Option, + permit_id: Option, + execution_id: Option, + causation_message_id: Option, +} + +struct ExternalRef { + kind: ExternalRefKind, + authority: BoundedName, + scope_digest: Digest, + value: BoundedOpaque, +} + +struct ActorRef { + actor_id: ActorId, + actor_kind: ActorKind, + issuer: BoundedName, + assurance: AuthAssurance, +} + +struct RuntimeBindingRef { + binding_id: RuntimeBindingId, + runtime_instance_id: RuntimeInstanceId, + runtime_generation: u64, + agent_session: ExternalRef, +} +``` + +`ExternalRef.value` may contain private tenant or user data. Domain and audit +events store an encrypted reference row ID or installation-keyed digest unless +the raw value is required for protocol continuation. Logs and errors use only +kind, digest, and safe suffix. + +### Durable relation draft + +```text +actors(actor_id, issuer, subject_digest, assurance, status) +tasks(task_id, owner_actor_id, target_ref, revision, ...) +runs(run_id, task_id, attempt, runtime_selector, ...) +agent_sessions(agent_session_id, task_id, runtime_kind, state, ...) +runtime_instances(runtime_instance_id, generation, launch_digest, ...) +runtime_bindings(binding_id, task_id, run_id, agent_session_id, runtime_instance_id, + runtime_generation, external_ref_id, status) +external_refs(external_ref_id, kind, authority, scope_digest, + value_ciphertext_or_value, value_digest) +approvals(approval_id, task_id, run_id, request_digest, ...) +permits(permit_id, approval_id?, task_id, run_id, target_digest, ...) +executions(execution_id, permit_id, idempotency_scope, ...) +deliveries(delivery_id, event_id, sink_digest, attempt, ...) +``` + +Foreign keys and unique constraints enforce the parent relations. Event JSON +is not the only place where correlation exists. + +## Correlation propagation + +### Ingress to Task + +1. Adapter verifies the transport credential and constructs issuer, subject, + conversation, and message references. +2. `IdentityResolver` returns an `ActorRef`; failure stops before Task + admission. +3. Gateway derives or accepts a bounded idempotency key. For channel messages, + it is an installation-keyed digest of the complete scoped message ref. +4. Coordinator creates or replays a Task command and assigns `TaskId` and + `MessageId`. +5. Raw credentials, webhook signatures, and bearer tokens are discarded + outside the adapter boundary. + +### Task to Runtime + +1. Coordinator creates `RunId` under `TaskId`. +2. Supervisor selects or spawns a `RuntimeInstanceId` and increments its + generation on every new process. +3. Coordinator selects or creates the COSH `AgentSessionId`; the bridge opens + or resumes a provider/ACP session and returns an opaque external session ref. +4. Coordinator commits `RuntimeBindingId` containing the logical Agent Session, + Runtime instance, generation, Run, and external reference. +5. Runtime events are accepted only when binding ID, instance ID, generation, + Run ID, and external scope all match the active record. + +### Permission to execution + +```text +TaskId + RunId + RuntimeBindingId + | + v +RequestId + AcpToolCallRef/provider tool ref + | + v +ApprovalId? -> PermitId -> ExecutionId -> evidence/audit refs +``` + +One tool call may cause zero, one, or several governed executions. Therefore +`ToolUseId` cannot be reused as `ExecutionId`. Repeated execution of one tool +call gets a new Execution ID unless an executor explicitly retries the same +idempotency scope. + +## State and sequence semantics + +- Task revision is the authoritative per-Task order. +- `MessageId` identifies a command or event and supports deduplication; it does + not imply order. +- `causation_message_id` points to the direct accepted input that caused an + event. `correlation.task_id` groups the full lifecycle. +- Runtime generation is a fencing token. Output from an older generation is + recorded as `stale_runtime_event` diagnostics and cannot mutate Task state. +- A request ID is unique within its protocol connection or COSH Run scope. + Database uniqueness uses the full scope, not the raw value. +- Channel retries with the same scoped message ref replay admission. A reused + ref with a different payload digest is a security conflict. +- Actor reassignment, target change, session rebinding, or approval delegation + is an explicit event; mutation of an existing identity row is forbidden. + +## Error and security boundaries + +- IDs are validated for prefix, canonical representation, length, and expected + type before database lookup. +- Authorization always checks Actor-to-Task access and target policy; knowing + a Task ID or ACP Session ID grants nothing. +- External references are capped in bytes and never used as paths, SQL text, + log templates, or environment names. +- Tenant/authority is part of channel scope to prevent cross-tenant message ID + collisions. +- The actor presented by a request cannot override the actor established by + the authenticated connection. +- `scope_digest` is installation-keyed where linkability outside the + installation would expose tenant or workspace information. +- Approval and execution accept only the active request digest. Stale or + replayed permits fail closed. +- Audit additions require schema review; existing v1 readers must not be + broken by silently adding required identity fields. + +## Compatibility and migration + +- Keep current `ProviderSessionId` UUID files unchanged and wrap them in + `ProviderSessionRef` at the bridge boundary. +- Keep existing Shell session, command, Run, request, and tool strings as + external/legacy references during dual operation. +- Add optional Task, Runtime binding, Approval, Permit, Execution, and Delivery + correlation only through an audit schema-compatible change or a v2 event + contract. +- Never backfill guessed Task IDs into legacy audit records. Readers report + an explicit correlation gap. +- On Gateway adoption, persist a binding event between the new Task and the + legacy provider session rather than renaming session files. + +## Dependencies + +- [Protocol contracts](../protocol-contracts/design.md) consumes these + newtypes and correlation rules. +- [Storage and supervision](../storage-supervision/design.md) persists + relations and enforces Runtime fencing. +- Phase 1 Gateway API owns authenticated actor admission. +- Phase 1 Broker owns Approval, Permit, and Execution correlation. +- Phase 2 ACP and Shell modules only translate scoped external references. + +## Implementation tasks + +1. Close the G0 contract-owner and UUID representation ADRs. **Ownership and + allocator ADR acceptance remain open.** +2. Implement validated internal ID newtypes and bounded external refs. + **Done for the leaf-contract layer.** +3. Add parent-relation constructors so orphan IDs cannot be serialized by + ordinary APIs. +4. Add actor resolution and provenance interfaces without transport secrets. +5. Add database constraints and lookup indexes for all scoped identities. +6. Add Runtime generation fencing at event admission. +7. Extend audit through an explicit schema compatibility decision. +8. Publish positive and adversarial identity fixtures. + +## Test strategy + +- Property tests prove type prefixes never cross-parse and serialization is + canonical. +- Database tests reject orphan Runs, cross-Task Approvals, reused permits, and + unscoped external IDs. +- Channel tests replay scoped message IDs and reject cross-tenant collisions or + changed-payload reuse. +- Runtime tests inject delayed events from a previous generation. +- ACP tests cover numeric versus string JSON-RPC IDs, duplicate tool call text + across sessions, missing message IDs, and Agent-chosen arbitrary session IDs. +- Security fixtures attempt ID enumeration, log injection, oversized opaque + values, and actor substitution. + +## Open decisions + +| Decision | Owner | Must close by | +| --- | --- | --- | +| Accept UUIDv4 allocation or migrate the allocator to UUIDv7 without changing typed prefixes | Contract owners | G0 exit | +| Raw-versus-encrypted storage for channel and ACP external values | Security and storage owners | Before Gateway schema migration 1 | +| Actor lifecycle and local UID remapping policy | Gateway API owner | Phase 1 admission implementation | +| Audit v1 additive fields versus a v2 audit schema | Audit owners | Before first Gateway audit event | +| Whether one ACP Session may bind concurrently to several Tasks | Runtime owner; recommended answer is no in Phase 2 | ACP bridge review | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design_zh.md new file mode 100644 index 0000000000..724f782eac --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/identity-correlation/design_zh.md @@ -0,0 +1,310 @@ +# Phase 0 Identity and Correlation 设计 + +[English](design.md) | [验收报告](acceptance_zh.md) | +[规划集](../../README_zh.md) + +## 状态与决策 + +- 基线:`up/main` 的 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 状态:typed identity 基础已实现;G0 storage/admission 退出条件尚未满足 + +COSH 必须分配自己的 typed lifecycle identity,并把渠道、Shell、cosh-core 与 +ACP identifier 保留为有 scope 的 external reference。只有 type 与 scope 都相同 +时才能判断 identity 相等。特别需要保持: + +```text +TaskId != RunId != AgentSessionId != ShellSessionId +RequestId != ToolUseId != ExecutionId != ApprovalId +``` + +External session 或 message identifier 可以定位 binding,但不能授权 actor、选择 +OS target,也不能成为 Task identifier。 + +## 目标 + +- 定义 canonical internal ID、external reference、ownership 与 scope。 +- 把一次 user intent 在 channel admission、Task event、Agent Runtime、approval、 + OS execution、audit 与 presentation delivery 之间关联起来。 +- 在副作用发生前识别 duplicate delivery、stale Runtime output 与 cross-task substitution。 +- 支持 local user、automation 和未来钉钉/飞书 adapter 的 actor resolution,同时 + 不在 domain event 中保存渠道 credential。 +- 扩展当前 audit 词表,不静默改变它的 v1 wire contract。 + +## 非目标 + +- 设计完整 IAM、organization directory、OAuth flow 或 channel login。 +- 把 Linux UID 单独作为全局稳定 human identity。 +- 把 internal identifier 当作 secret。ID 是不可猜测的 correlation handle,不是 + authorization token。 +- 因为文本碰巧相同,就复用 provider、ACP、JSON-RPC 或 Shell ID。 +- 在 Phase 0 迁移现有 provider session filename 或 audit event。 + +## 当前源码证据 + +| 证据 | 基线事实 | 缺口 | +| --- | --- | --- | +| [`ProviderSessionId`](../../../../../crates/cosh-core/src/session.rs#L23) | Persistence 只接受 canonical lowercase UUID,并把它绑定到 canonical workspace | 它只标识 provider history,不是 Task 或 actor | +| [`AuditIdentity`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) | Audit event 可携带 installation、Shell session、provider session、Run、turn、request、tool 与 command string | 字段是 optional string,并且没有 Task、Approval、Execution、Delivery、actor、target 或 Runtime generation | +| [`ShellEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L69) | Shell session 和 command identity 与 provider audit identity 分离 | 没有 durable Gateway binding | +| [`AgentRequest`](../../../../../crates/cosh-shell/src/types/mod.rs#L303) 与 [`AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L402) | `AgentRequest.id` 作为 string Run ID 流入 Shell event | 缺少 type 与 generation fencing | +| [`ProviderToolKey`](../../../../../crates/cosh-shell/src/runtime/provider_tool_state.rs#L236) | In-memory tool correlation 已要求 `(run_id, tool_id)` | Shell 退出后 scope 丢失,且不是 durable execution identity | +| [`RunCommand`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service/command.rs#L21) | Persistent Core service 携带 Run ID 与独立 session scope | Process binding 没有 durable owner/generation record | + +实现 worktree 已增加 canonical +[`ids`](../../../../../crates/cosh-gateway-contracts/src/ids.rs)、 +[`common`](../../../../../crates/cosh-gateway-contracts/src/common.rs) 与 +[`external`](../../../../../crates/cosh-gateway-contracts/src/external.rs) module。 +Durable external-reference registry、actor resolver 与 storage constraint 不在此 +leaf-contract 切片内。 + +## Ownership + +| Owner | 职责 | +| --- | --- | +| `cosh-gateway-contracts` leaf crate | ID newtype、`Correlation`、`ExternalRef`、constructor validation contract 与 serializable scope type | +| Gateway `IdentityResolver` | 认证 ingress,把 issuer/subject 映射到 `ActorId` 并输出 provenance | +| Task Coordinator | 分配 Task、Run、Approval、Execution、Delivery 与 command Message ID,执行 parent-child invariant | +| Runtime Supervisor | 分配 Runtime instance/generation 与 Connection ID,注册 Agent Session binding | +| Runtime bridge | 把 provider 与 ACP value 保留为 opaque scoped reference,不从中推断语义 | +| Capability Broker | 绑定 actor、Task、Run、target、operation、permit、Approval 与 Execution ID | +| Audit projection | 通过另行评审的 schema revision 添加 optional correlation field,或以 reference 映射 | +| Channel adapter | 构造 bounded issuer-specific external reference 与 idempotency material,不分配 internal ownership | + +`cosh-gateway-contracts` 的 G0 ownership ADR 同时管理这些 newtype。`cosh-shell` +保持 standalone 边界,除非后续 ADR 允许直接依赖,否则通过 canonical fixture +mirror Gateway wire ID。 + +## Identity 分类 + +### COSH 分配的 internal identity + +| Type | Scope 与 lifetime | Parent/invariant | +| --- | --- | --- | +| `InstallationId` | 一个 local Gateway installation,持久 | 不能只从 hostname 或 machine-id 推导 | +| `ActorId` | 本 installation 已知 principal,持久 | 映射自 authenticated `(issuer, subject)` | +| `TaskId` | Durable user intent | Owner 与 target policy context 不可变 | +| `RunId` | 一次 Runtime turn 或 workflow run 尝试 | 只属于一个 `TaskId`;retry 创建新 Run | +| `AgentSessionId` | COSH logical Agent conversation binding | 属于一个 Task context;映射 provider/ACP external session,但不采用其 ID | +| `RuntimeInstanceId` | 一个 supervised child process | 对应一个 launch specification 与 generation sequence | +| `RuntimeBindingId` | Run 与 external Agent Session 的 binding | 绑定一个 Task、Run、Runtime instance、generation 与 external session ref | +| `ApprovalId` | 一个 durable decision request | 绑定一个 Task、Run、request digest 与 policy revision | +| `PermitId` | 一个 Broker authorization result | 绑定所需 Approval、target、operation digest 与 expiry | +| `ExecutionId` | 一次 side effect 尝试 | 绑定一个 permit;只有 executor 定义了幂等 replay 时才能复用 | +| `DeliveryId` | 一个 Outbox event 到一个 sink 的 delivery | 绑定一个 event 与 destination;attempt 单独计数 | +| `MessageId` | 一个 COSH command/event envelope | 在 installation 内全局唯一 | + +Internal ID 使用短 type prefix 加 canonical lowercase hyphenated UUIDv4 text, +例如 `tsk_` 与 `run_`。Allocator 与 workspace 集中的 `uuid` feature +及 Rust 1.88 baseline 保持一致。Durable ordering 仍使用 Task revision 或 database +sequence,绝不使用 UUID ordering。未来可在保持 prefix text contract 不变的前提下 +迁移到 UUIDv7,但需要显式 compatibility decision。 + +### 有 Scope 的 external identity + +| Type | 必需 scope | 规则 | +| --- | --- | --- | +| `ChannelConversationRef` | adapter + authority/tenant + conversation | Opaque 且 bounded,不是 actor | +| `ChannelMessageRef` | conversation ref + message value | 提供 ingress deduplication material | +| `ShellSessionRef` | installation + Shell process/session | 本身不能恢复 provider context | +| `ShellCommandRef` | Shell session + command ID | 只标识 PTY evidence | +| `ProviderSessionRef` | Runtime kind + workspace + provider session value | 可映射当前 `ProviderSessionId`,但不是 Task ID | +| `AcpConnectionRef` | Runtime instance + generation | 为 stdio connection correlation 在本地分配 | +| `AcpSessionRef` | ACP connection + opaque Agent session ID | 只能通过显式 binding 跨 Run 复用 | +| `AcpRequestRef` | ACP connection + JSON-RPC ID | JSON-RPC number 与 string 保持不同 wire value | +| `AcpMessageRef` | ACP session + opaque optional message ID | Message ID 缺失时使用 local chunk sequence,不伪造 Agent identity | +| `AcpToolCallRef` | ACP session + opaque tool call ID | 映射一个 internal tool observation,不直接映射 Execution | +| `TerminalRef` | Runtime binding + ACP terminal ID | 只在 terminal ownership record 存在时有效 | + +External value 与 scope 分开存储。任何代码都不能通过拼接未经 escape 的 string +创建 composite primary key。 + +## Typed schema + +已提交源码实现下列 shape。以下简化视图省略 helper method 与 validation 细节。 + +```rust +struct Correlation { + installation_id: InstallationId, + actor_id: Option, + task_id: Option, + run_id: Option, + agent_session_id: Option, + runtime_binding_id: Option, + approval_id: Option, + permit_id: Option, + execution_id: Option, + causation_message_id: Option, +} + +struct ExternalRef { + kind: ExternalRefKind, + authority: BoundedName, + scope_digest: Digest, + value: BoundedOpaque, +} + +struct ActorRef { + actor_id: ActorId, + actor_kind: ActorKind, + issuer: BoundedName, + assurance: AuthAssurance, +} + +struct RuntimeBindingRef { + binding_id: RuntimeBindingId, + runtime_instance_id: RuntimeInstanceId, + runtime_generation: u64, + agent_session: ExternalRef, +} +``` + +`ExternalRef.value` 可能包含私有 tenant 或 user data。除非 protocol continuation +必须使用 raw value,否则 domain 与 audit event 只保存 encrypted reference row ID +或 installation-keyed digest。Log 与 error 只能使用 kind、digest 与 safe suffix。 + +### Durable relation 草案 + +```text +actors(actor_id, issuer, subject_digest, assurance, status) +tasks(task_id, owner_actor_id, target_ref, revision, ...) +runs(run_id, task_id, attempt, runtime_selector, ...) +agent_sessions(agent_session_id, task_id, runtime_kind, state, ...) +runtime_instances(runtime_instance_id, generation, launch_digest, ...) +runtime_bindings(binding_id, task_id, run_id, agent_session_id, runtime_instance_id, + runtime_generation, external_ref_id, status) +external_refs(external_ref_id, kind, authority, scope_digest, + value_ciphertext_or_value, value_digest) +approvals(approval_id, task_id, run_id, request_digest, ...) +permits(permit_id, approval_id?, task_id, run_id, target_digest, ...) +executions(execution_id, permit_id, idempotency_scope, ...) +deliveries(delivery_id, event_id, sink_digest, attempt, ...) +``` + +Foreign key 与 unique constraint 执行 parent relation。Correlation 不能只存在 +于 event JSON 中。 + +## Correlation 传播 + +### Ingress 到 Task + +1. Adapter 校验 transport credential,并构造 issuer、subject、conversation 与 + message reference。 +2. `IdentityResolver` 返回 `ActorRef`;失败时在 Task admission 前停止。 +3. Gateway 推导或接受 bounded idempotency key。对于 channel message,它是完整 + scoped message ref 的 installation-keyed digest。 +4. Coordinator 创建或 replay Task command,并分配 `TaskId` 与 `MessageId`。 +5. Raw credential、webhook signature 与 bearer token 在 adapter boundary 外丢弃。 + +### Task 到 Runtime + +1. Coordinator 在 `TaskId` 下创建 `RunId`。 +2. Supervisor 选择或启动 `RuntimeInstanceId`,每次新 process 都增加 generation。 +3. Coordinator 选择或创建 COSH `AgentSessionId`;Bridge 打开或恢复 provider/ACP + session,并返回 opaque external session ref。 +4. Coordinator 持久化 `RuntimeBindingId`,其中包含 logical Agent Session、Runtime + instance、generation、Run 与 external reference。 +5. 只有 binding ID、instance ID、generation、Run ID 与 external scope 全部匹配 + active record,Runtime event 才会被接受。 + +### Permission 到 Execution + +```text +TaskId + RunId + RuntimeBindingId + | + v +RequestId + AcpToolCallRef/provider tool ref + | + v +ApprovalId? -> PermitId -> ExecutionId -> evidence/audit refs +``` + +一个 tool call 可能产生零个、一个或多个 governed execution。因此不能把 +`ToolUseId` 复用为 `ExecutionId`。同一个 tool call 的重复 execution 使用新的 +Execution ID,除非 executor 明确 retry 同一 idempotency scope。 + +## 状态与序列语义 + +- Task revision 是权威 per-Task order。 +- `MessageId` 标识 command/event 并支持 deduplication,不表示 order。 +- `causation_message_id` 指向直接导致 event 的 accepted input; + `correlation.task_id` 聚合完整 lifecycle。 +- Runtime generation 是 fencing token。旧 generation 的 output 记录为 + `stale_runtime_event` diagnostics,不能修改 Task state。 +- Request ID 在对应 protocol connection 或 COSH Run scope 内唯一。Database + uniqueness 使用完整 scope,不使用 raw value。 +- 使用相同 scoped message ref 的 channel retry replay admission。同一 ref 携带 + 不同 payload digest 属于 security conflict。 +- Actor reassignment、target change、session rebinding 或 approval delegation + 必须形成显式 event,禁止修改已有 identity row。 + +## Error 与安全边界 + +- Database lookup 前校验 ID prefix、canonical representation、length 与 expected type。 +- Authorization 始终检查 Actor-to-Task access 与 target policy;知道 Task ID 或 + ACP Session ID 不授予任何权限。 +- External reference 有 byte 上限,不能用作 path、SQL text、log template 或 + environment name。 +- Channel scope 包含 tenant/authority,防止跨 tenant message ID collision。 +- Request payload 提供的 actor 不能覆盖 authenticated connection 确立的 actor。 +- 如果跨 installation linkability 会暴露 tenant 或 workspace 信息,`scope_digest` + 必须使用 installation-keyed digest。 +- Approval 与 execution 只接受 active request digest;stale 或 replay permit fail closed。 +- Audit 扩展需要 schema review;不得通过静默新增 required identity field 破坏 v1 reader。 + +## 兼容与迁移 + +- 保持当前 `ProviderSessionId` UUID file 不变,在 bridge boundary 把它包装成 + `ProviderSessionRef`。 +- Dual operation 期间,把现有 Shell session、command、Run、request 与 tool string + 保留为 external/legacy reference。 +- Task、Runtime binding、Approval、Permit、Execution 与 Delivery correlation 只能 + 通过 audit schema-compatible change 或 v2 event contract 添加。 +- 绝不向 legacy audit record 回填猜测的 Task ID。Reader 要报告显式 correlation gap。 +- Gateway 启用时,持久化新 Task 与 legacy provider session 的 binding event, + 不重命名 session file。 + +## 依赖 + +- [Protocol Contracts](../protocol-contracts/design_zh.md)消费这些 newtype 与 + correlation rule。 +- [Storage and Supervision](../storage-supervision/design_zh.md)持久化 relation 并执行 + Runtime fencing。 +- Phase 1 Gateway API 拥有 authenticated actor admission。 +- Phase 1 Broker 拥有 Approval、Permit 与 Execution correlation。 +- Phase 2 ACP 与 Shell module 只转换 scoped external reference。 + +## 实施任务 + +1. 关闭 G0 contract-owner 与 UUID representation ADR。**Ownership 与 allocator + ADR acceptance 尚未完成。** +2. 实现 validated internal ID newtype 与 bounded external ref。 + **Leaf-contract layer 已完成。** +3. 添加 parent-relation constructor,使普通 API 无法序列化 orphan ID。 +4. 添加不包含 transport secret 的 actor resolution 与 provenance interface。 +5. 为所有 scoped identity 添加 database constraint 与 lookup index。 +6. 在 event admission 中添加 Runtime generation fencing。 +7. 通过显式 schema compatibility decision 扩展 audit。 +8. 发布 positive 与 adversarial identity fixture。 + +## 测试策略 + +- Property test 证明不同 type prefix 不能 cross-parse,serialization 为 canonical。 +- Database test 拒绝 orphan Run、cross-Task Approval、reused permit 与 unscoped + external ID。 +- Channel test replay scoped message ID,并拒绝 cross-tenant collision 或 payload + 已改变的 reuse。 +- Runtime test 注入 previous generation 的 delayed event。 +- ACP test 覆盖 numeric/string JSON-RPC ID、跨 session 重复 tool call text、缺失 + message ID 与 Agent 自选 arbitrary session ID。 +- Security fixture 尝试 ID enumeration、log injection、oversized opaque value 与 + actor substitution。 + +## 开放决策 + +| 决策 | Owner | 最晚关闭时间 | +| --- | --- | --- | +| 接受 UUIDv4 allocation,或在不改变 typed prefix 的前提下迁移 allocator 到 UUIDv7 | Contract owner | G0 退出前 | +| Channel 与 ACP external value 使用 raw storage 还是 encrypted storage | Security 与 storage owner | Gateway schema migration 1 前 | +| Actor lifecycle 与 local UID remapping policy | Gateway API owner | Phase 1 admission 实现前 | +| Audit v1 additive field 或 v2 audit schema | Audit owner | 第一个 Gateway audit event 前 | +| 一个 ACP Session 是否允许同时绑定多个 Task | Runtime owner;Phase 2 建议不允许 | ACP bridge review | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance.md new file mode 100644 index 0000000000..c2adbb63b7 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance.md @@ -0,0 +1,165 @@ +# Phase 0 Protocol Contracts Acceptance Report + +[中文版](acceptance_zh.md) | [Design](design.md) | +[Planning set](../../README.md) + +## Baseline result + +**The leaf-contract slice is accepted; Phase 0 implementation exit is not.** +This report covers the implementation worktree based on +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. The worktree also contains a Task +reducer, SQLite Task storage, Runtime primitives, and a process-local +Capability Broker slice. It does not claim complete schemas, fixtures, +coordinator/port integration, durable Broker authority, or a complete ACP bridge. + +The new side-effect-free package provides neutral Task, Runtime, Capability, +Approval, execution, header, and error types. Its deserializers validate typed +IDs, schema version and envelope kind, bounded text, opaque values, digests, +and error codes. Individual scalar fields are bounded; aggregate `Vec` and +envelope collection totals are not yet capped and therefore do not satisfy a +complete bounded-input claim. + +## Evidence reviewed + +| Source | Verified baseline behavior | +| --- | --- | +| [`protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs#L9), symbols `CONTROL_PROTOCOL_VERSION`, `InputMessage`, `OutputMessage` | Exact product-specific shell/core protocol version `1`; not ACP | +| [`AgentAdapter`](../../../../../crates/cosh-shell/src/adapter/mod.rs#L87), [`AgentRunHandle`](../../../../../crates/cosh-shell/src/adapter/mod.rs#L107), and [`AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L402) | Shell-local Agent lifecycle abstraction exists | +| [`session.rs`](../../../../../crates/cosh-core/src/session.rs#L83), symbols `PersistedSession`, `SessionError` | Versioned provider-session envelope and typed errors exist | +| [`types/audit.rs`](../../../../../crates/cosh-shell/src/types/audit.rs#L29), symbol `AuditIdentity` | Multiple correlation strings exist, without Task or Execution identity | +| [`cosh-gateway-contracts`](../../../../../crates/cosh-gateway-contracts/src/lib.rs) and its [manifest](../../../../../crates/cosh-gateway-contracts/Cargo.toml) | Side-effect-free leaf crate depends only on workspace `serde`, `thiserror`, and `uuid`; no ACP, transport, async, storage, or OS dependency | +| [`task.rs`](../../../../../crates/cosh-gateway-contracts/src/task.rs) and [`runtime.rs`](../../../../../crates/cosh-gateway-contracts/src/runtime.rs) | Versioned command/event envelopes and neutral Task/Runtime payloads are public and documented | +| [`capability.rs`](../../../../../crates/cosh-gateway-contracts/src/capability.rs) and [`error.rs`](../../../../../crates/cosh-gateway-contracts/src/error.rs) | Capability request/decision/permit and bounded machine-readable errors are implemented | +| [`aggregate.rs`](../../../../../crates/cosh-gateway/src/task/aggregate.rs) and [`task_store.rs`](../../../../../crates/cosh-gateway/src/storage/task_store.rs) | Task transitions, revision checks, terminal guards, transactional event/projection/receipt/Outbox writes, and idempotency replay/conflict tests exist | +| [`capability/broker.rs`](../../../../../crates/cosh-gateway/src/capability/broker.rs) and [`capability/memory.rs`](../../../../../crates/cosh-gateway/src/capability/memory.rs) | Broker-facing policy branches and process-local atomic single-use permit checks exist; approval and authority are not durable | +| [Implemented runtime contract](../../../../../docs/design/runtime-contracts.md) | Existing JSONL negotiation and process path remain compatibility inputs | + +Review covered source, dependency direction, rustdoc, serialization tests, and +targeted package validation. It did not call a provider, access ECS, or mutate +the host. + +## Acceptance matrix + +| ID | Requirement | Baseline | Evidence required to pass | +| --- | --- | --- | --- | +| PC-01 | Neutral Task command and event types exist in the accepted side-effect-free owner | Partial | Rust types, rustdoc, and dependency direction pass; ownership ADR and schema fixtures remain | +| PC-02 | Runtime Port types contain no ACP, cosh-core, Shell, HTTP, or channel type | Partial | Neutral Runtime command/event types pass; behavioral port and API review remain | +| PC-03 | Capability request, permit, approval, and execution outcomes are typed | Partial | Public serde types and eight Broker-facing tests pin target/descriptor/complete-operation digest/scope before policy; trusted canonicalizer tests, golden schemas, durable approval, and execution-result lifecycle remain | +| PC-04 | Product schema versions are independent from ACP and core versions | Partial | Explicit schema constant and fail-closed version/type tests pass; compatibility manifest remains | +| PC-05 | Task reducer enforces monotonic revisions and one terminal Run event | Partial | Critical revision-gap and terminal-guard tests pass; duplicate/reorder property matrix remains | +| PC-06 | Command idempotency specifies same-key/same-digest replay and conflict | Partial | SQLite integration replays the same actor/key/digest and rejects a changed digest; authenticated ingress scope and fixture corpus remain | +| PC-07 | Cancellation persists intent and resolves completion races deterministically | Partial | Cancellation intent/terminal facts and reducer guards exist; fake-runtime completion-race fixtures remain | +| PC-08 | Errors are bounded, redacted, stable, and machine-readable | Partial | Scalar code/message construction and deserialization pass; aggregate collection caps and secret-scanner adversarial fixtures remain | +| PC-09 | ACP v1 baseline and capability negotiation are represented in fixtures | Missing | Official SDK-generated initialize/session fixtures | +| PC-10 | Existing shell/core JSONL v1 remains compatible | Ready as baseline only | Existing protocol suite plus new CoshCore bridge fixtures | +| PC-11 | Unknown versions and unsupported capabilities fail closed | Partial | Unknown Gateway schema version and envelope-kind mismatch fail closed; ACP fake-Agent negative tests remain | +| PC-12 | English and Chinese design/acceptance pairs remain equivalent | Ready for this change after doc checks | Parity review recorded below | + +`Partial` means the leaf source or an existing precedent covers only part of +the requirement; the remaining evidence in the last column is still required. + +## Required fixture inventory + +Implementation cannot exit Phase 0 until the repository contains versioned +fixtures equivalent to: + +```text +fixtures/gateway-contracts/v1/ + gateway-command-create-task.json + gateway-command-idempotency-conflict.json + task-event-run-lifecycle.jsonl + task-event-approval-execution.jsonl + runtime-command-prompt.json + runtime-event-message-tool-permission.jsonl + capability-request.json + execution-permit.json + contract-error.json + malformed/ + unknown-schema-version.json + oversized-content.json + cross-task-correlation.json +fixtures/acp/v1/ + initialize-minimal.jsonl + initialize-capabilities.jsonl + prompt-cancel.jsonl + permission-terminal.jsonl +fixtures/cosh-core-bridge/v1/ + initialize-and-turn.jsonl + approval-and-host-execution.jsonl +``` + +Fixture paths remain a proposed artifact layout; they are not provided by the +leaf-contract slice. + +## Required validation commands + +The remaining implementation acceptance must record these equivalent commands +and counts: + +```bash +cargo test --package cosh-gateway-contracts +cargo test --package cosh-gateway task_reducer +cargo test --package cosh-gateway --test contract_fixtures +cargo test --package cosh-shell --test protocol +``` + +Also required: + +- JSON Schema validation for every positive and malformed fixture; +- dependency graph evidence showing domain contracts do not depend on ACP or + transport crates; +- a generated compatibility manifest recording ACP wire `1`, actual ACP SDK + package version, Gateway schema `1`, and core control protocol `1` as + distinct values; +- diff evidence that current shell/core protocol fixtures still pass. + +Targeted leaf-crate validation recorded for this slice: + +```text +cargo fmt --package cosh-gateway-contracts -- --check +cargo test --locked --package cosh-gateway-contracts +cargo clippy --locked --package cosh-gateway-contracts --all-targets -- -D warnings +cargo doc --locked --package cosh-gateway-contracts --no-deps +cargo tree --locked --package cosh-gateway-contracts --edges normal +result: 6 integration tests passed; unit and doc-test targets passed +dependency result: serde, thiserror, and uuid only +``` + +## Missing implementation + +- No complete Gateway schema/golden-fixture corpus or aggregate collection limits. +- No integrated Task coordinator and behavioral mapping across Runtime, + Capability Broker, Presentation, and storage ports. +- No durable approval/permit/execution Broker ledger or reconciliation path; + the current Broker store is process-local. +- The official ACP Rust SDK, codec, and fake-Agent tests now exist at the + library boundary; the canonical versioned fixture corpus and real-adapter + evidence remain incomplete. +- No CoshCore Bridge translating current JSONL to neutral events. +- No compatibility manifest or rollout feature flag. + +## Exit criteria + +Phase 0 protocol contracts pass only when: + +1. PC-01 through PC-12 have implementation evidence at one exact commit. +2. All required schemas and fixtures are reviewed and versioned. +3. State and cancellation property tests pass deterministically. +4. ACP fixtures are produced or consumed through the pinned official Rust SDK, + with `protocolVersion = 1` asserted separately from the SDK version. +5. No external transport or runtime-specific type appears in Task storage. +6. Security review confirms bounded input and secret-free error behavior. +7. The existing shell/core protocol target remains green. +8. Open decisions that affect a public type or schema are closed in an ADR or + accepted design revision. + +## Validation recorded for this slice + +- English and Chinese files use the required reciprocal links. +- Command blocks and schema names are identical across languages. +- Relative source links were checked from this directory. +- Markdown whitespace and diff hygiene were checked. +- Targeted formatting, package tests, Clippy, rustdoc, and dependency audit + passed with the commands recorded above. +- ECS, provider, and host-mutation validation was intentionally skipped because + this crate has no I/O or host behavior. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance_zh.md new file mode 100644 index 0000000000..cbaf16de84 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/acceptance_zh.md @@ -0,0 +1,154 @@ +# Phase 0 Protocol Contracts 验收报告 + +[English](acceptance.md) | [设计](design_zh.md) | +[规划集](../../README_zh.md) + +## 基线结论 + +**Leaf-contract 切片已通过,Phase 0 实现退出条件尚未达到。** 本报告覆盖基于 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 的实现 worktree。Worktree 还包含 Task +reducer、SQLite Task storage、Runtime primitive 与 process-local Capability Broker +切片,但不表示完整 schema、fixture、coordinator/port integration、durable Broker +authority 或完整 ACP bridge 已存在。 + +新的 side-effect-free package 提供中立 Task、Runtime、Capability、Approval、 +execution、header 与 error type。Deserializer 会校验 typed ID、schema version、 +envelope kind、bounded text、opaque value、digest 与 error code。单个 scalar field +已 bounded,但 aggregate `Vec` 与 envelope collection total 尚未设上限,因此不构成 +完整 bounded-input 声明。 + +## 已审计证据 + +| 来源 | 已核实的基线行为 | +| --- | --- | +| [`protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs#L9) 中的 `CONTROL_PROTOCOL_VERSION`、`InputMessage`、`OutputMessage` | Exact version 为 `1` 的产品专用 Shell/Core protocol,不是 ACP | +| [`AgentAdapter`](../../../../../crates/cosh-shell/src/adapter/mod.rs#L87)、[`AgentRunHandle`](../../../../../crates/cosh-shell/src/adapter/mod.rs#L107) 与 [`AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L402) | 已有 Shell-local Agent lifecycle abstraction | +| [`session.rs`](../../../../../crates/cosh-core/src/session.rs#L83) 中的 `PersistedSession`、`SessionError` | 已有 versioned provider-session envelope 与 typed error | +| [`types/audit.rs`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) 中的 `AuditIdentity` | 已有多个 correlation string,但没有 Task 或 Execution identity | +| [`cosh-gateway-contracts`](../../../../../crates/cosh-gateway-contracts/src/lib.rs) 与其 [manifest](../../../../../crates/cosh-gateway-contracts/Cargo.toml) | Side-effect-free leaf crate 只依赖 workspace `serde`、`thiserror` 与 `uuid`;不依赖 ACP、transport、async、storage 或 OS | +| [`task.rs`](../../../../../crates/cosh-gateway-contracts/src/task.rs) 与 [`runtime.rs`](../../../../../crates/cosh-gateway-contracts/src/runtime.rs) | Versioned command/event envelope 与中立 Task/Runtime payload 已公开并带 rustdoc | +| [`capability.rs`](../../../../../crates/cosh-gateway-contracts/src/capability.rs) 与 [`error.rs`](../../../../../crates/cosh-gateway-contracts/src/error.rs) | Capability request/decision/permit 与 bounded machine-readable error 已实现 | +| [`aggregate.rs`](../../../../../crates/cosh-gateway/src/task/aggregate.rs) 与 [`task_store.rs`](../../../../../crates/cosh-gateway/src/storage/task_store.rs) | Task transition、revision check、terminal guard、transactional event/projection/receipt/Outbox write 与 idempotency replay/conflict test 已存在 | +| [`capability/broker.rs`](../../../../../crates/cosh-gateway/src/capability/broker.rs) 与 [`capability/memory.rs`](../../../../../crates/cosh-gateway/src/capability/memory.rs) | Broker-facing policy branch 与 process-local atomic single-use permit check 已存在;approval 与 authority 不持久 | +| [已实现 Runtime contract](../../../../../docs/design/runtime-contracts.md) | 当前 JSONL negotiation 与 process path 仍是兼容输入 | + +审计覆盖 source、dependency direction、rustdoc、serialization test 与 targeted +package validation。没有调用 provider、访问 ECS 或修改 host。 + +## 验收矩阵 + +| ID | 要求 | 基线 | 通过所需证据 | +| --- | --- | --- | --- | +| PC-01 | 中立 Task command/event type 位于已接受的 side-effect-free owner | 部分 | Rust type、rustdoc 与 dependency direction 通过;ownership ADR 与 schema fixture 尚未完成 | +| PC-02 | Runtime Port type 不含 ACP、cosh-core、Shell、HTTP 或渠道 type | 部分 | 中立 Runtime command/event type 通过;behavioral port 与 API review 尚未完成 | +| PC-03 | Capability request、permit、approval 与 execution outcome 全部 typed | 部分 | Public serde type 与八个 Broker-facing test 在 policy 前 pin target/descriptor/完整 operation digest/scope;trusted canonicalizer test、golden schema、durable approval 与 execution-result lifecycle 尚未完成 | +| PC-04 | Product schema version 与 ACP、Core version 相互独立 | 部分 | 显式 schema constant 与 fail-closed version/type test 通过;compatibility manifest 尚未完成 | +| PC-05 | Task reducer 保证 monotonic revision 和一个 terminal Run event | 部分 | Critical revision-gap 与 terminal-guard test 通过;duplicate/reorder property matrix 尚未完成 | +| PC-06 | Command idempotency 定义同 key 同 digest replay 与 conflict | 部分 | SQLite integration replay 同 actor/key/digest,并拒绝 changed digest;authenticated ingress scope 与 fixture corpus 尚未完成 | +| PC-07 | Cancellation 持久化 intent,并确定性处理 completion race | 部分 | Cancellation intent/terminal fact 与 reducer guard 已存在;fake-runtime completion-race fixture 尚未完成 | +| PC-08 | Error bounded、redacted、stable 且 machine-readable | 部分 | Scalar code/message construction 与 deserialization 通过;aggregate collection cap 和 secret-scanner adversarial fixture 尚未完成 | +| PC-09 | ACP v1 baseline 与 capability negotiation 有 fixture | 缺失 | 官方 SDK 生成的 initialize/session fixture | +| PC-10 | 现有 Shell/Core JSONL v1 保持兼容 | 仅基线已就绪 | 现有 protocol suite 与新 CoshCore bridge fixture | +| PC-11 | Unknown version 与 unsupported capability fail closed | 部分 | Unknown Gateway schema version 与 envelope-kind mismatch 已 fail closed;ACP fake-Agent negative test 尚未完成 | +| PC-12 | 中英文 design/acceptance pair 语义等价 | 本次文档检查后就绪 | 下方记录的 parity review | + +“部分”表示 leaf source 或已有先例只覆盖部分要求,最后一列列出的证据仍需补齐。 + +## 必要 Fixture 清单 + +在仓库包含下列等价 versioned fixture 前,Phase 0 实现不能退出: + +```text +fixtures/gateway-contracts/v1/ + gateway-command-create-task.json + gateway-command-idempotency-conflict.json + task-event-run-lifecycle.jsonl + task-event-approval-execution.jsonl + runtime-command-prompt.json + runtime-event-message-tool-permission.jsonl + capability-request.json + execution-permit.json + contract-error.json + malformed/ + unknown-schema-version.json + oversized-content.json + cross-task-correlation.json +fixtures/acp/v1/ + initialize-minimal.jsonl + initialize-capabilities.jsonl + prompt-cancel.jsonl + permission-terminal.jsonl +fixtures/cosh-core-bridge/v1/ + initialize-and-turn.jsonl + approval-and-host-execution.jsonl +``` + +以上路径仍是建议 artifact layout,leaf-contract 切片未提供这些文件。 + +## 必要验证命令 + +后续实现验收必须记录以下等价命令与 count: + +```bash +cargo test --package cosh-gateway-contracts +cargo test --package cosh-gateway task_reducer +cargo test --package cosh-gateway --test contract_fixtures +cargo test --package cosh-shell --test protocol +``` + +还必须提供: + +- 所有 positive 与 malformed fixture 的 JSON Schema validation; +- 证明 domain contract 不依赖 ACP 或 transport crate 的 dependency graph; +- 自动生成的 compatibility manifest,把 ACP wire `1`、实际 ACP SDK package + version、Gateway schema `1` 与 Core control protocol `1` 作为不同值记录; +- 当前 Shell/Core protocol fixture 仍然通过的 diff evidence。 + +本切片记录的 targeted leaf-crate validation: + +```text +cargo fmt --package cosh-gateway-contracts -- --check +cargo test --locked --package cosh-gateway-contracts +cargo clippy --locked --package cosh-gateway-contracts --all-targets -- -D warnings +cargo doc --locked --package cosh-gateway-contracts --no-deps +cargo tree --locked --package cosh-gateway-contracts --edges normal +result: 6 integration tests passed;unit 与 doc-test target passed +dependency result:仅 serde、thiserror 与 uuid +``` + +## 未实现项 + +- 没有完整 Gateway schema/golden-fixture corpus 或 aggregate collection limit。 +- 没有贯通 Runtime、Capability Broker、Presentation 与 storage port 的 integrated + Task coordinator 和 behavioral mapping。 +- 没有 durable approval/permit/execution Broker ledger 或 reconciliation path; + 当前 Broker store 只在进程内有效。 +- 官方 ACP Rust SDK、codec 与 fake-Agent test 已存在于 library boundary;canonical + versioned fixture corpus 与 real-adapter 证据仍不完整。 +- 没有把当前 JSONL 转成中立 event 的 CoshCore Bridge。 +- 没有 compatibility manifest 或 rollout feature flag。 + +## Exit Criteria + +只有满足下列条件,Phase 0 protocol contract 才能通过: + +1. PC-01 至 PC-12 在同一个准确 commit 上都有实现证据。 +2. 所有必要 schema 与 fixture 已评审并 versioned。 +3. State 与 cancellation property test 确定性通过。 +4. ACP fixture 通过 pinned official Rust SDK 产生或消费,并单独断言 + `protocolVersion = 1`,不与 SDK version 混淆。 +5. Task storage 不包含 external transport 或 runtime-specific type。 +6. Security review 确认 bounded input 与 secret-free error behavior。 +7. 现有 Shell/Core protocol target 保持通过。 +8. 影响 public type 或 schema 的开放决策已通过 ADR 或 accepted design revision 关闭。 + +## 本切片的验证记录 + +- 中英文文件包含规定的双向链接。 +- Command block 与 schema 名在两种语言中完全一致。 +- 已从当前目录检查相对源码链接。 +- 已检查 Markdown whitespace 与 diff hygiene。 +- 上述 targeted formatting、package test、Clippy、rustdoc 与 dependency audit + 已通过。 +- 由于该 crate 没有 I/O 或 host behavior,有意跳过 ECS、provider 与 host-mutation + validation。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design.md new file mode 100644 index 0000000000..d59feab8d0 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design.md @@ -0,0 +1,403 @@ +# Phase 0 Protocol Contracts Design + +[中文版](design_zh.md) | [Acceptance report](acceptance.md) | +[Planning set](../../README.md) + +## Status and decision + +- Baseline: `up/main` at `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Status: leaf contract foundation implemented; Phase 0 freeze remains open +- ACP profile: wire protocol v1 with `initialize.protocolVersion = 1` + +The side-effect-free +[`cosh-gateway-contracts`](../../../../../crates/cosh-gateway-contracts/src/lib.rs) +crate now implements the first COSH-owned domain-contract slice. ACP types +remain inside the ACP bridge. Existing cosh-core JSONL messages remain inside +the CoshCore bridge. The Task Execution Plane speaks only the neutral commands, +events, runtime messages, and capability requests defined here. Complete schema +and golden-fixture corpora, reducer property matrices, integrated coordinator +ports, and bridges remain Phase 0/1 work. + +The ACP protocol version and SDK package version are independent. The candidate +bridge negotiates ACP protocol `1`, pins official SDK 2.0.0, and raises the +cosh-ng Rust/RPM baseline to 1.88. An SDK package major is never inferred from +the wire version. + +## Goals + +- Freeze versioned Task, Runtime, Capability, Approval, and presentation event + envelopes shared by Phases 1 and 2. +- Prevent ACP, cosh-core, Shell, HTTP, DingTalk, or Feishu payloads from + becoming durable domain objects. +- Define command idempotency, event ordering, cancellation, terminal outcome, + and error semantics before storage code exists. +- Preserve the current standalone `cosh-shell` crate boundary. +- Provide schemas and golden fixtures that bridges can test independently. + +## Non-goals + +- Implementing the Gateway daemon, database, Runtime Supervisor, or ACP bridge. +- Replacing the existing shell/core control protocol in Phase 0. +- Standardizing a remote ACP transport. Phase 2 uses local stdio only. +- Defining provider prompts, model APIs, OS policy rules, or channel-specific + authentication payloads. +- Promising exactly-once external side effects. The contract provides durable + admission and idempotent execution identities; executors must still prove + their own replay behavior. + +## Current-source evidence + +| Evidence | Baseline fact | Contract implication | +| --- | --- | --- | +| [`CONTROL_PROTOCOL_VERSION`](../../../../../crates/cosh-core/src/protocol.rs#L9) and [`InputMessage`](../../../../../crates/cosh-core/src/protocol.rs#L60) | cosh-core accepts a product-specific JSONL protocol with exact version `1` | This is not ACP v1 and must remain behind `CoshCoreBridge` | +| [`OutputMessage`](../../../../../crates/cosh-core/src/protocol.rs#L202) and [`CoreControlRequest`](../../../../../crates/cosh-core/src/protocol.rs#L360) | Streaming, approval, questions, and shell evidence use core-specific shapes | Bridge translation is required; these types cannot enter Task storage | +| [`AgentAdapter`](../../../../../crates/cosh-shell/src/adapter/mod.rs#L87) and [`AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L402) | A useful lifecycle boundary exists, but IDs and payloads are Shell-owned strings | Reuse semantics, not Rust types, in the neutral Runtime Port | +| [`PersistedSession`](../../../../../crates/cosh-core/src/session.rs#L83) | Provider conversation history already has a versioned envelope | Provider Session remains separate from durable Task and Event contracts | +| [`AuditIdentity`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) | Cross-runtime correlation fields exist for audit events | The Phase 0 identity module extends and types this vocabulary | +| [Runtime contracts](../../../../../docs/design/runtime-contracts.md) | Current ownership and negotiation are documented as implemented | Migration must keep this path usable until the bridge is accepted | + +The implementation worktree adds the neutral leaf types but no ACP dependency, +`TaskCoordinator`, Task event store, Outbox, or behavioral Runtime Port. + +## Module ownership + +| Owner | Planned responsibility | Explicit exclusions | +| --- | --- | --- | +| `cosh-gateway-contracts` leaf crate | Pure serializable IDs, command/event envelopes, error codes, and enums; schema fixtures remain pending | I/O, async traits, ACP SDK types, database records | +| Future `cosh-gateway::ports` | Behavioral traits for `TaskStore`, `AgentRuntimePort`, `CapabilityBrokerPort`, and `PresentationPort` | Transport parsing and provider-specific logic | +| `cosh-gateway` coordinator | Validate commands, enforce state transitions, append events, and invoke ports | Parsing ACP or cosh-core JSONL directly | +| `CoshCoreBridge` | Translate neutral Runtime messages to the existing JSONL protocol | Owning Task state or authorization decisions | +| `AcpClientBridge` | Translate ACP v1 SDK types and callbacks to neutral Runtime and Capability contracts | Serving channel traffic or persisting ACP wire messages | +| `cosh-shell` attachment | Mirror the public Gateway wire contract and verify it with shared fixtures | Depending on internal crates or supervising Agent processes after migration | + +`cosh-gateway-contracts` is implemented as a side-effect-free leaf crate, not a +module of the existing `cosh-types`. G0 still requires an accepted ownership +ADR and versioned schemas/fixtures. Ownership and dependency direction are +frozen: pure types point inward, transports and bridges point toward them, and +no domain crate depends on an SDK. This choice +does not change the standalone/no-internal-dependency rule for `cosh-shell`; +the first Shell attachment uses a Gateway wire client/mirror plus canonical +fixtures. A direct dependency on the leaf crate requires a separate ADR. + +## Contract layers + +```text +Channel / Shell / Web payload + | + v +Gateway command envelope ---------> Task command + | + v + durable Task event + | + +----------------------+-------------------+ + v v + AgentRuntimePort command Presentation event + | + v + Capability request -> decision -> execution result +``` + +Only Task events are authoritative lifecycle facts. Runtime updates and +presentation events become durable only after the coordinator validates and +records them. + +## Typed contract + +The committed modules are +[`task`](../../../../../crates/cosh-gateway-contracts/src/task.rs), +[`runtime`](../../../../../crates/cosh-gateway-contracts/src/runtime.rs), +[`capability`](../../../../../crates/cosh-gateway-contracts/src/capability.rs), +and [`error`](../../../../../crates/cosh-gateway-contracts/src/error.rs). The +following abbreviated definitions describe their stable semantics; committed +Rust types are authoritative for field-level detail. All identifiers are +validated newtypes from the identity module. + +```rust +struct ContractHeader { + schema: &'static str, + schema_version: u16, + message_id: MessageId, + occurred_at_ms: u64, + correlation: Correlation, +} + +struct GatewayCommandEnvelope { + header: ContractHeader, + actor: ActorRef, + idempotency_key: IdempotencyKey, + expected_task_revision: Option, + command: TaskCommand, +} + +enum TaskCommand { + CreateTask { intent: BoundedText, target: TargetRef }, + StartRun { task_id: TaskId, runtime: RuntimeSelector }, + SubmitInput { task_id: TaskId, content: Vec }, + ResolveApproval { approval_id: ApprovalId, decision: ApprovalDecision }, + CancelRun { task_id: TaskId, run_id: RunId, reason: CancelReason }, + Attach { task_id: TaskId, cursor: Option }, +} + +struct TaskEventEnvelope { + header: ContractHeader, + task_id: TaskId, + revision: u64, + event: TaskEvent, +} + +enum TaskEvent { + TaskSubmitted { intent_digest: Digest, target: TargetRef }, + TaskQueued { run_id: RunId, runtime: RuntimeSelector }, + RunStarted { run_id: RunId }, + RuntimeBound { run_id: RunId, binding: RuntimeBindingRef }, + RuntimeEventRecorded { run_id: RunId, update: RuntimeUpdate }, + ApprovalRequested { approval: ApprovalRequest }, + ApprovalResolved { approval_id: ApprovalId, decision: ApprovalDecision }, + ExecutionPlanned { execution_id: ExecutionId, permit_id: PermitId }, + ExecutionResultRecorded { execution_id: ExecutionId, outcome: ExecutionOutcome }, + ExecutionUncertain { execution_id: ExecutionId, reason: UncertaintyCode }, + CancellationRequested { run_id: RunId, cause: CancelReason }, + RunCancelled { run_id: RunId, stage: CancellationStage }, + RunSuspended { run_id: RunId, reason: SuspensionCode }, + RunSucceeded { run_id: RunId }, + RunFailed { run_id: RunId, error: ContractError }, + RunRetryQueued { previous_run_id: RunId, next_run_id: RunId }, + TaskSucceeded, + TaskFailed { error: ContractError }, + TaskCancelled, +} + +enum AgentRuntimeCommand { + OpenSession { task_id: TaskId, run_id: RunId, workspace: WorkspaceRef }, + ResumeSession { task_id: TaskId, run_id: RunId, binding: RuntimeBindingRef }, + Prompt { run_id: RunId, input: Vec }, + ResolvePermission { request_id: RequestId, decision: RuntimePermissionDecision }, + Cancel { run_id: RunId, cause: CancelReason }, + Close { binding: RuntimeBindingRef }, +} + +enum AgentRuntimeEvent { + SessionOpened { binding: RuntimeBindingRef }, + MessageChunk { message_id: RuntimeMessageId, content: ContentPart }, + ToolCallObserved { tool_use_id: ToolUseId, summary: ToolSummary }, + PermissionRequested { request: CapabilityRequest }, + UsageUpdated { usage: RuntimeUsage }, + Completed { outcome: RunOutcome }, + TransportFailed { error: RuntimeError }, +} +``` + +Required wire schemas are named `cosh.gateway.command`, `cosh.task.event`, +`cosh.runtime.command`, and `cosh.runtime.event`, each starting at schema +version `1`. Schema version is unrelated to ACP `protocolVersion`. + +### Capability contract + +```rust +struct CapabilityRequest { + request_id: RequestId, + task_id: TaskId, + run_id: RunId, + actor: ActorRef, + target: TargetRef, + operation: OperationDescriptor, + operation_digest: Digest, + requested_scope: CapabilityScope, + input_digest: Digest, + expires_at_ms: u64, +} + +enum CapabilityDecision { + Permit { permit: ExecutionPermit }, + RequireApproval { approval: ApprovalRequest }, + Deny { code: DenialCode, safe_message: BoundedText }, +} + +struct ExecutionPermit { + permit_id: PermitId, + request_id: RequestId, + target: TargetRef, + operation_digest: Digest, + valid_until_ms: u64, + single_use: bool, +} +``` + +A permit is bound to target, normalized operation digest, requesting Run, and +expiry. Bridges never manufacture permits. ACP `session/request_permission` +is translated to this request and only a broker decision is translated back. + +`operation_digest` covers the complete canonical namespace, operation name, +and normalized arguments. `OperationDescriptor.arguments_digest` is narrower +policy detail and cannot be used as permit authority. Trusted admission owns +canonicalization and hashing, then pins target, the complete descriptor, +operation digest, and requested scope before Broker policy evaluation. + +### Error envelope + +```rust +struct ContractError { + code: ErrorCode, + category: ErrorCategory, + retryable: bool, + safe_message: BoundedText, + retry_after_ms: Option, + details_ref: Option, +} + +enum ErrorCategory { + InvalidRequest, + Conflict, + NotFound, + Unauthorized, + PolicyDenied, + RuntimeUnavailable, + Transport, + Storage, + Cancelled, + Internal, +} +``` + +Raw provider errors, stderr, secrets, prompts, and stack traces never enter +`safe_message`. Detailed diagnostics use bounded, redacted evidence. + +## State and sequence semantics + +### Command admission + +1. Resolve and authenticate `ActorRef` before constructing the domain command. +2. Look up `(actor, idempotency_key, command_kind)`. +3. If an identical payload digest was accepted, return its original result. +4. If the key exists with another digest, return `idempotency_conflict`. +5. Validate `expected_task_revision` when supplied. +6. Append Task event, update projection, and enqueue Outbox entries in one + storage transaction. +7. Invoke Runtime or presentation work only after the transaction commits. + +### Runtime turn + +```text +TaskSubmitted -> TaskQueued -> RunStarted -> RuntimeEventRecorded* + | | + | +-> ApprovalRequested + | -> ApprovalResolved + | -> ExecutionPlanned + | -> ExecutionResultRecorded + +-> RunSucceeded | RunCancelled | RunFailed | RunSuspended +``` + +- Exactly one terminal Run event is accepted for a Run revision. +- Late Runtime updates are retained as diagnostics, not applied to a terminal + projection. +- Runtime session IDs and JSON-RPC request IDs are scoped opaque references; + neither determines Task identity. +- A cancellation request is durable before transport cancellation begins. + Completion that wins the race remains completion; otherwise the coordinator + records the final cancellation stage. +- Event cursors address the durable Task sequence, not an ACP notification + order or a process-local channel offset. + +## ACP v1 compatibility profile + +Phase 2 must implement the stable v1 baseline methods `initialize`, +`session/new`, `session/prompt`, `session/cancel`, and `session/update`. +Optional lifecycle, content, filesystem, terminal, elicitation, and config +features are enabled only when negotiated. The bridge must: + +- send `protocolVersion: 1` and close cleanly if the Agent selects an + unsupported version; +- treat omitted capabilities as unsupported; +- use local newline-delimited JSON-RPC over stdin/stdout; +- preserve ACP Session IDs, JSON-RPC IDs, message IDs, and tool call IDs as + opaque external references; +- translate `session/request_permission`, `terminal/*`, and `fs/*` into COSH + governance rather than executing them in the protocol reader; +- implement `session/cancel` semantics and capability-gated + `$/cancel_request` without equating transport cancellation with durable Task + completion; +- keep the draft ACP Streamable HTTP transport outside the Phase 0-2 contract. + +Normative references: [ACP v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization), +[prompt turn](https://agentclientprotocol.com/protocol/v1/prompt-turn), +[cancellation](https://agentclientprotocol.com/protocol/v1/cancellation), and +[transports](https://agentclientprotocol.com/protocol/v1/transports). + +## Error and security boundaries + +- Unknown schema versions fail before state mutation. +- Unknown enum values are rejected unless the field is explicitly declared + forward-compatible and retained as bounded opaque metadata. +- Deserializers enforce byte, collection, nesting, and text limits. +- External paths are canonicalized at the Capability Broker; a path in an ACP + message is not an authorization grant. +- Channel actors cannot supply internal IDs, ownership, policy results, or + event revisions. +- Runtime updates are untrusted input until correlated with the active fenced + Runtime binding. +- Approval responses require actor authorization and the exact pending + `ApprovalId`; a JSON-RPC response alone does not authorize execution. +- Secrets and full environment maps are forbidden in durable contracts. + +## Compatibility and migration + +1. Add types, schemas, and fixtures without changing existing JSONL behavior. +2. Wrap the current `AgentAdapter` and cosh-core JSONL path in a + `CoshCoreBridge` that emits neutral Runtime events. +3. Run bridge conformance fixtures alongside current protocol tests. +4. Move durable ownership to the Gateway only after Phase 1 acceptance. +5. Migrate Shell to attachment mode in Phase 2; retain an explicit fallback + to the current local runtime during the compatibility window. +6. Remove or change a v1 field only through a schema-v2 decision and migration + fixtures. Additive optional fields remain v1-compatible. + +Existing `ProviderSessionId` files, audit segments, and shell/core control +protocol version `1` are not rewritten by this plan. + +## Dependencies + +- [Identity and correlation](../identity-correlation/design.md) freezes ID + constructors, scopes, and inheritance. +- [Storage and supervision](../storage-supervision/design.md) freezes atomic + persistence and child-process ownership. +- Phase 1 Gateway, Task Plane, Broker, and CoshCore Bridge consume these + contracts. +- Phase 2 ACP, Shell, and Web modules provide transport adapters only. + +## Implementation tasks + +1. Add pure newtypes and envelopes to the selected contract owner. **Done.** +2. Publish JSON Schema and canonical JSON fixtures for every message kind. +3. Add bounded deserialization and stable error codes. **Scalar values and + errors are bounded; aggregate collection totals remain open.** +4. Define port traits using only neutral types. +5. Add state-machine reducers with property tests for terminal uniqueness and + revision monotonicity. **Critical reducer tests exist; the property matrix + remains open.** +6. Add CoshCore translation fixtures against the existing JSONL protocol. +7. Add ACP v1 fixtures generated by the pinned official SDK. +8. Add a compatibility manifest mapping product schema, ACP protocol, SDK, + and cosh-core control versions independently. + +## Test strategy + +- Schema tests validate every golden example and reject unknown required + fields, oversized inputs, and invalid ID scopes. +- Round-trip tests cover JSON serialization without relying on field order. +- State-machine property tests permute duplicate, delayed, cancelled, and + terminal events. +- Bridge contract tests run without a real model or host mutation. +- ACP conformance uses a deterministic fake Agent over stdio. +- Security tests attempt cross-task ID substitution, permit replay, stale + Runtime generations, path escapes, and secret-bearing errors. + +## Open decisions + +| Decision | Owner | Must close by | +| --- | --- | --- | +| Accept the implemented `cosh-gateway-contracts` ownership in an ADR and land its v1 schemas/fixtures | cosh-ng maintainers | Before G0 exit | +| UUIDv4-to-UUIDv7 allocator migration and compatibility | Identity module | Identity contract review | +| Public Gateway wire encoding beyond JSON v1 | Gateway API module | Phase 1 API freeze | +| Which ACP stabilized optional capabilities enter the first conformance profile | ACP bridge owner | Phase 2 implementation start | +| Duration of the Shell fallback compatibility window | Product and runtime owners | Phase 2 rollout review | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design_zh.md new file mode 100644 index 0000000000..ae8e1f7002 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/protocol-contracts/design_zh.md @@ -0,0 +1,384 @@ +# Phase 0 Protocol Contracts 设计 + +[English](design.md) | [验收报告](acceptance_zh.md) | +[规划集](../../README_zh.md) + +## 状态与决策 + +- 基线:`up/main` 的 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 状态:leaf contract 基础已实现;Phase 0 freeze 尚未关闭 +- ACP profile:wire protocol v1,`initialize.protocolVersion = 1` + +Side-effect-free +[`cosh-gateway-contracts`](../../../../../crates/cosh-gateway-contracts/src/lib.rs) +crate 已实现第一批 COSH 自有 domain contract。ACP type 只能留在 ACP bridge 内, +现有 cosh-core JSONL message 只能留在 CoshCore bridge 内。Task Execution Plane +只使用本文定义的中立 command、event、runtime message 和 capability request。 +完整 schema/golden-fixture corpus、reducer property matrix、integrated coordinator +port 与 bridge 仍属于 Phase 0/1 工作。 + +ACP protocol version 与 SDK package version 相互独立。候选 Bridge 协商 ACP protocol +`1`,准确固定官方 SDK 2.0.0,并把 cosh-ng Rust/RPM baseline 提升到 1.88。 +绝不能从 wire version 推断 SDK package major。 + +## 目标 + +- 冻结 Phase 1 和 Phase 2 共用的 Task、Runtime、Capability、Approval 与 + presentation event envelope。 +- 防止 ACP、cosh-core、Shell、HTTP、钉钉或飞书 payload 成为持久 domain object。 +- 在存储代码出现前定义 command idempotency、event ordering、cancellation、 + terminal outcome 与 error semantics。 +- 保留当前 `cosh-shell` 独立 crate 边界。 +- 提供各 bridge 可以独立验证的 schema 与 golden fixture。 + +## 非目标 + +- 实现 Gateway daemon、database、Runtime Supervisor 或 ACP bridge。 +- 在 Phase 0 替换现有 Shell/Core control protocol。 +- 标准化远端 ACP transport。Phase 2 只使用本地 stdio。 +- 定义 provider prompt、model API、OS policy rule 或渠道专用 authentication payload。 +- 承诺外部副作用 exactly-once。契约提供持久 admission 与幂等 execution identity, + executor 仍要自行证明 replay behavior。 + +## 当前源码证据 + +| 证据 | 基线事实 | 契约含义 | +| --- | --- | --- | +| [`CONTROL_PROTOCOL_VERSION`](../../../../../crates/cosh-core/src/protocol.rs#L9) 与 [`InputMessage`](../../../../../crates/cosh-core/src/protocol.rs#L60) | cosh-core 接收 exact version 为 `1` 的产品专用 JSONL protocol | 它不是 ACP v1,必须留在 `CoshCoreBridge` 后 | +| [`OutputMessage`](../../../../../crates/cosh-core/src/protocol.rs#L202) 与 [`CoreControlRequest`](../../../../../crates/cosh-core/src/protocol.rs#L360) | streaming、approval、question 与 Shell evidence 使用 Core 专用 shape | 必须由 bridge 转换,这些 type 不得进入 Task storage | +| [`AgentAdapter`](../../../../../crates/cosh-shell/src/adapter/mod.rs#L87) 与 [`AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs#L402) | 已有可用的 lifecycle boundary,但 ID 和 payload 是 Shell 自有 string | 中立 Runtime Port 复用语义,不复用 Rust type | +| [`PersistedSession`](../../../../../crates/cosh-core/src/session.rs#L83) | Provider conversation history 已有 versioned envelope | Provider Session 与持久 Task、Event contract 保持分离 | +| [`AuditIdentity`](../../../../../crates/cosh-shell/src/types/audit.rs#L29) | Audit event 已有跨 runtime correlation field | Phase 0 identity module 扩展并类型化该词表 | +| [Runtime contract](../../../../../docs/design/runtime-contracts.md) | 当前 ownership 与 negotiation 已按实现记录 | 迁移必须在 bridge 通过验收前保持该路径可用 | + +实现 worktree 已增加中立 leaf type,但仍不存在 ACP dependency、 +`TaskCoordinator`、Task event store、Outbox 或 behavioral Runtime Port。 + +## 模块 Ownership + +| Owner | 规划职责 | 明确排除 | +| --- | --- | --- | +| `cosh-gateway-contracts` leaf crate | 纯 serializable ID、command/event envelope、error code 与 enum;schema fixture 尚未完成 | I/O、async trait、ACP SDK type、database record | +| 未来的 `cosh-gateway::ports` | `TaskStore`、`AgentRuntimePort`、`CapabilityBrokerPort` 与 `PresentationPort` behavioral trait | Transport parsing 与 provider 专用逻辑 | +| `cosh-gateway` coordinator | 校验 command、执行 state transition、追加 event 并调用 port | 直接解析 ACP 或 cosh-core JSONL | +| `CoshCoreBridge` | 在中立 Runtime message 与现有 JSONL protocol 之间转换 | 拥有 Task state 或 authorization decision | +| `AcpClientBridge` | 把 ACP v1 SDK type 和 callback 转换为中立 Runtime、Capability contract | 承载渠道流量或持久化 ACP wire message | +| `cosh-shell` attachment | mirror 公共 Gateway wire contract,并用共享 fixture 验证 | 依赖内部 crate,或迁移后监督 Agent process | + +`cosh-gateway-contracts` 已作为 side-effect-free leaf crate 实现,它不等同于现有 +`cosh-types` 的一个 module。G0 仍需通过 ownership ADR,并补齐 versioned schema/ +fixture。本文冻结 ownership 和 dependency direction:pure +type 指向内层,transport 与 bridge 指向 pure type,domain crate 不依赖 SDK。该 +选择不会改变 `cosh-shell` 的 standalone/no-internal-dependency 规则;首版 Shell +attachment 通过 Gateway wire client/mirror 与 canonical fixture 保持一致。若要直接 +依赖该 leaf crate,必须另行通过 ADR。 + +## Contract 分层 + +```text +Channel / Shell / Web payload + | + v +Gateway command envelope ---------> Task command + | + v + durable Task event + | + +----------------------+-------------------+ + v v + AgentRuntimePort command Presentation event + | + v + Capability request -> decision -> execution result +``` + +只有 Task event 是 lifecycle 的权威事实。Runtime update 与 presentation event +必须先由 coordinator 校验并记录,才能成为持久事实。 + +## Typed contract + +已提交的模块包括 +[`task`](../../../../../crates/cosh-gateway-contracts/src/task.rs)、 +[`runtime`](../../../../../crates/cosh-gateway-contracts/src/runtime.rs)、 +[`capability`](../../../../../crates/cosh-gateway-contracts/src/capability.rs) 与 +[`error`](../../../../../crates/cosh-gateway-contracts/src/error.rs)。以下简化定义描述 +稳定语义,字段级细节以已提交的 Rust type 为准。所有 ID 都是 identity module +中的 validated newtype。 + +```rust +struct ContractHeader { + schema: &'static str, + schema_version: u16, + message_id: MessageId, + occurred_at_ms: u64, + correlation: Correlation, +} + +struct GatewayCommandEnvelope { + header: ContractHeader, + actor: ActorRef, + idempotency_key: IdempotencyKey, + expected_task_revision: Option, + command: TaskCommand, +} + +enum TaskCommand { + CreateTask { intent: BoundedText, target: TargetRef }, + StartRun { task_id: TaskId, runtime: RuntimeSelector }, + SubmitInput { task_id: TaskId, content: Vec }, + ResolveApproval { approval_id: ApprovalId, decision: ApprovalDecision }, + CancelRun { task_id: TaskId, run_id: RunId, reason: CancelReason }, + Attach { task_id: TaskId, cursor: Option }, +} + +struct TaskEventEnvelope { + header: ContractHeader, + task_id: TaskId, + revision: u64, + event: TaskEvent, +} + +enum TaskEvent { + TaskSubmitted { intent_digest: Digest, target: TargetRef }, + TaskQueued { run_id: RunId, runtime: RuntimeSelector }, + RunStarted { run_id: RunId }, + RuntimeBound { run_id: RunId, binding: RuntimeBindingRef }, + RuntimeEventRecorded { run_id: RunId, update: RuntimeUpdate }, + ApprovalRequested { approval: ApprovalRequest }, + ApprovalResolved { approval_id: ApprovalId, decision: ApprovalDecision }, + ExecutionPlanned { execution_id: ExecutionId, permit_id: PermitId }, + ExecutionResultRecorded { execution_id: ExecutionId, outcome: ExecutionOutcome }, + ExecutionUncertain { execution_id: ExecutionId, reason: UncertaintyCode }, + CancellationRequested { run_id: RunId, cause: CancelReason }, + RunCancelled { run_id: RunId, stage: CancellationStage }, + RunSuspended { run_id: RunId, reason: SuspensionCode }, + RunSucceeded { run_id: RunId }, + RunFailed { run_id: RunId, error: ContractError }, + RunRetryQueued { previous_run_id: RunId, next_run_id: RunId }, + TaskSucceeded, + TaskFailed { error: ContractError }, + TaskCancelled, +} + +enum AgentRuntimeCommand { + OpenSession { task_id: TaskId, run_id: RunId, workspace: WorkspaceRef }, + ResumeSession { task_id: TaskId, run_id: RunId, binding: RuntimeBindingRef }, + Prompt { run_id: RunId, input: Vec }, + ResolvePermission { request_id: RequestId, decision: RuntimePermissionDecision }, + Cancel { run_id: RunId, cause: CancelReason }, + Close { binding: RuntimeBindingRef }, +} + +enum AgentRuntimeEvent { + SessionOpened { binding: RuntimeBindingRef }, + MessageChunk { message_id: RuntimeMessageId, content: ContentPart }, + ToolCallObserved { tool_use_id: ToolUseId, summary: ToolSummary }, + PermissionRequested { request: CapabilityRequest }, + UsageUpdated { usage: RuntimeUsage }, + Completed { outcome: RunOutcome }, + TransportFailed { error: RuntimeError }, +} +``` + +必须提供 `cosh.gateway.command`、`cosh.task.event`、`cosh.runtime.command` 和 +`cosh.runtime.event` wire schema,并全部从 schema version `1` 开始。Schema +version 与 ACP `protocolVersion` 无关。 + +### Capability contract + +```rust +struct CapabilityRequest { + request_id: RequestId, + task_id: TaskId, + run_id: RunId, + actor: ActorRef, + target: TargetRef, + operation: OperationDescriptor, + operation_digest: Digest, + requested_scope: CapabilityScope, + input_digest: Digest, + expires_at_ms: u64, +} + +enum CapabilityDecision { + Permit { permit: ExecutionPermit }, + RequireApproval { approval: ApprovalRequest }, + Deny { code: DenialCode, safe_message: BoundedText }, +} + +struct ExecutionPermit { + permit_id: PermitId, + request_id: RequestId, + target: TargetRef, + operation_digest: Digest, + valid_until_ms: u64, + single_use: bool, +} +``` + +Permit 必须绑定 target、规范化 operation digest、发起请求的 Run 与 expiry。 +Bridge 不能创建 permit。ACP `session/request_permission` 要先转换成该 request, +只有 Broker decision 才能转换回 ACP response。 + +`operation_digest` 覆盖完整 canonical namespace、operation name 与 normalized +arguments。`OperationDescriptor.arguments_digest` 只是更窄的 policy detail,不能用作 +permit authority。Trusted admission 负责 canonicalization 与 hashing,并在 Broker +policy evaluation 前 pin target、完整 descriptor、operation digest 与 requested scope。 + +### Error envelope + +```rust +struct ContractError { + code: ErrorCode, + category: ErrorCategory, + retryable: bool, + safe_message: BoundedText, + retry_after_ms: Option, + details_ref: Option, +} + +enum ErrorCategory { + InvalidRequest, + Conflict, + NotFound, + Unauthorized, + PolicyDenied, + RuntimeUnavailable, + Transport, + Storage, + Cancelled, + Internal, +} +``` + +Raw provider error、stderr、secret、prompt 与 stack trace 都不得进入 +`safe_message`。详细诊断通过 bounded、redacted evidence 暴露。 + +## 状态与序列语义 + +### Command admission + +1. 在构造 domain command 前解析并认证 `ActorRef`。 +2. 查询 `(actor, idempotency_key, command_kind)`。 +3. 相同 payload digest 已被接受时,返回原结果。 +4. 同一 key 对应另一 digest 时,返回 `idempotency_conflict`。 +5. 如果提供 `expected_task_revision`,必须进行校验。 +6. 在同一个 storage transaction 中追加 Task event、更新 projection 并加入 + Outbox entry。 +7. Transaction commit 后才能调用 Runtime 或 presentation work。 + +### Runtime turn + +```text +TaskSubmitted -> TaskQueued -> RunStarted -> RuntimeEventRecorded* + | | + | +-> ApprovalRequested + | -> ApprovalResolved + | -> ExecutionPlanned + | -> ExecutionResultRecorded + +-> RunSucceeded | RunCancelled | RunFailed | RunSuspended +``` + +- 一个 Run revision 只能接受一个 terminal Run event。 +- 迟到的 Runtime update 作为诊断保留,但不再应用到 terminal projection。 +- Runtime session ID 与 JSON-RPC request ID 都是有 scope 的 opaque reference, + 二者都不能确定 Task identity。 +- Transport cancellation 开始前,cancellation request 必须先持久化。如果 + completion 赢得竞争,保留 completion;否则 coordinator 记录最终 cancellation stage。 +- Event cursor 指向 durable Task sequence,不指向 ACP notification order 或 + process-local channel offset。 + +## ACP v1 Compatibility Profile + +Phase 2 必须实现稳定 v1 baseline method `initialize`、`session/new`、 +`session/prompt`、`session/cancel` 与 `session/update`。可选 lifecycle、content、 +filesystem、terminal、elicitation 与 config feature 只能在协商成功时启用。 +Bridge 必须做到: + +- 发送 `protocolVersion: 1`;Agent 选择不支持的版本时 cleanly close; +- 把未提供的 capability 视为 unsupported; +- 通过 stdin/stdout 使用本地 newline-delimited JSON-RPC; +- 把 ACP Session ID、JSON-RPC ID、message ID 与 tool call ID 保持为 opaque + external reference; +- 把 `session/request_permission`、`terminal/*` 与 `fs/*` 转换到 COSH governance, + protocol reader 不得直接执行; +- 实现 `session/cancel` 语义和经过 capability gate 的 `$/cancel_request`,且不把 + transport cancellation 等同于 durable Task completion; +- 把仍处于草案状态的 ACP Streamable HTTP transport 排除在 Phase 0-2 contract 外。 + +规范资料:[ACP v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization)、 +[prompt turn](https://agentclientprotocol.com/protocol/v1/prompt-turn)、 +[cancellation](https://agentclientprotocol.com/protocol/v1/cancellation) 与 +[transports](https://agentclientprotocol.com/protocol/v1/transports)。 + +## Error 与安全边界 + +- 在 state mutation 前拒绝未知 schema version。 +- 未知 enum value 默认拒绝;只有明确声明 forward-compatible 的字段才能作为 + bounded opaque metadata 保留。 +- Deserializer 必须限制 byte、collection、nesting 与 text size。 +- 外部 path 在 Capability Broker 中 canonicalize;ACP message 中的 path 不构成授权。 +- 渠道 actor 不能提供 internal ID、ownership、policy result 或 event revision。 +- Runtime update 在与 active fenced Runtime binding 关联前都属于不可信输入。 +- Approval response 必须通过 actor authorization 并匹配准确的 pending + `ApprovalId`;仅有 JSON-RPC response 不足以授权执行。 +- Durable contract 禁止 secret 与完整 environment map。 + +## 兼容与迁移 + +1. 添加 type、schema 与 fixture,不改变当前 JSONL behavior。 +2. 用 `CoshCoreBridge` 包装当前 `AgentAdapter` 和 cosh-core JSONL 路径,并输出 + 中立 Runtime event。 +3. 新 bridge conformance fixture 与当前 protocol test 并行运行。 +4. Phase 1 验收完成后,才把 durable ownership 移交 Gateway。 +5. Phase 2 把 Shell 迁移为 attachment mode;兼容窗口内显式保留当前 local runtime + fallback。 +6. 删除或改变 v1 field 必须通过 schema-v2 决策与 migration fixture;新增 optional + field 保持 v1 compatibility。 + +本规划不重写现有 `ProviderSessionId` file、audit segment 或 Shell/Core control +protocol version `1`。 + +## 依赖 + +- [Identity and Correlation](../identity-correlation/design_zh.md)冻结 ID constructor、 + scope 与 inheritance。 +- [Storage and Supervision](../storage-supervision/design_zh.md)冻结 atomic persistence + 与 child-process ownership。 +- Phase 1 Gateway、Task Plane、Broker 与 CoshCore Bridge 消费这些 contract。 +- Phase 2 ACP、Shell 与 Web module 只提供 transport adapter。 + +## 实施任务 + +1. 在选定的 contract owner 中添加 pure newtype 与 envelope。**已完成。** +2. 为每一种 message 发布 JSON Schema 与 canonical JSON fixture。 +3. 添加 bounded deserialization 与 stable error code。**Scalar value 与 error 已 + bounded;aggregate collection total 仍未完成。** +4. 只用中立 type 定义 port trait。 +5. 添加 state-machine reducer,并用 property test 验证 terminal uniqueness 与 + revision monotonicity。**Critical reducer test 已存在;property matrix 尚未完成。** +6. 针对现有 JSONL protocol 添加 CoshCore translation fixture。 +7. 添加由 pinned official SDK 生成的 ACP v1 fixture。 +8. 添加 compatibility manifest,分别记录 product schema、ACP protocol、SDK 与 + cosh-core control version。 + +## 测试策略 + +- Schema test 校验所有 golden example,并拒绝未知 required field、oversized input + 与错误 ID scope。 +- Round-trip test 覆盖 JSON serialization,不依赖 field order。 +- State-machine property test 组合 duplicate、delayed、cancelled 与 terminal event。 +- Bridge contract test 不调用真实 model,也不修改 host。 +- ACP conformance 使用通过 stdio 运行的 deterministic fake Agent。 +- Security test 尝试 cross-task ID substitution、permit replay、stale Runtime + generation、path escape 与包含 secret 的 error。 + +## 开放决策 + +| 决策 | Owner | 最晚关闭时间 | +| --- | --- | --- | +| 通过 ADR 接受已实现的 `cosh-gateway-contracts` ownership,并补齐 v1 schema/fixture | cosh-ng maintainers | G0 退出前 | +| UUIDv4 到 UUIDv7 的 allocator migration 与兼容性 | Identity module | Identity contract review | +| JSON v1 之外的公共 Gateway wire encoding | Gateway API module | Phase 1 API freeze | +| 首批 conformance profile 包含哪些已稳定 ACP optional capability | ACP bridge owner | Phase 2 开始实现前 | +| Shell fallback 兼容窗口长度 | Product 与 runtime owner | Phase 2 rollout review | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance.md new file mode 100644 index 0000000000..3abd641501 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance.md @@ -0,0 +1,182 @@ +# Phase 0 Storage and Supervision Acceptance Baseline + +[中文版](acceptance_zh.md) | [Design](design.md) | +[Planning set](../../README.md) + +## Baseline result + +**ADR direction accepted for planning; implementation readiness not accepted.** +The inspected source is +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. + +The baseline has secure provider-session file persistence and several mature +process-tree cleanup paths. It has no SQLite dependency, Gateway Task store, +Outbox, Runtime Supervisor, daemon recovery, or generation fencing. + +## First ADR-S1 implementation result + +**Storage result: VERIFIED FIRST SLICE; STORAGE EXIT NOT ACCEPTED.** The current working-tree +candidate implements the Task transaction and local SQLite connection policy. Runtime supervision +is evaluated separately and the root integration report owns its final status. + +Recorded on 2026-08-13: + +- `cargo test --locked --package cosh-gateway storage --no-fail-fast`: 14/14 passed. +- `cargo test --locked --package cosh-gateway task::aggregate --no-fail-fast`: 6/6 passed. +- `cargo clippy --locked --package cosh-gateway --lib -- -D warnings`: passed. +- Automated evidence covers WAL/FULL/foreign-key policy, actor and revision substitution, atomic + Task/Event/receipt/Outbox rollback, checksummed and newer-schema failure, deterministic reopen + recovery, causation rows, relative paths, insecure parents without chmod, and intermediate or + final symlinks. + +Result vocabulary: `PASS` is complete reproducible evidence; `PARTIAL` is a verified production +slice with named gaps; `NOT IMPLEMENTED` or `Missing` means no production path; `BLOCKED` means a +named dependency prevents validation. + +## Evidence reviewed + +| Source/symbol | Verified fact | +| --- | --- | +| [`SessionStore::persist`](../../../../../crates/cosh-core/src/session/store.rs#L125) | Uses validation, locking, generation conflict detection, redaction, bounds, and atomic file commit for one provider-session aggregate | +| [`ScopedStorage`](../../../../../crates/cosh-core/src/session/scoped.rs#L27) | Uses private permissions, descriptor-relative operations, no-follow opens, and temporary-file cleanup | +| [`CoshCoreService::new`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs#L106) | Shell starts a worker that owns persistent cosh-core process state | +| [`service_loop`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs#L283) | Shell resets or shuts down its core child based on per-turn state | +| [`spawn_provider_child`](../../../../../crates/cosh-shell/src/adapter/process.rs#L66) | Provider process gets a new session, piped I/O, and bounded retry | +| [`run_provider_process_loop`](../../../../../crates/cosh-shell/src/adapter/process.rs#L190) | Watchdog, bounded stderr, cancellation escalation, and reap exist in Shell | +| [`output_with_timeout`](../../../../../crates/cosh-core/src/process.rs#L72) | Core helper subprocess cleanup covers timeouts and caller cancellation | +| [`Cargo.toml`](../../../../../Cargo.toml) | No SQLite dependency is declared | + +No provider, ECS, privileged, or live process tests were run. The commands above are local targeted +tests for the first implementation slice; the historical baseline itself remains documentation +evidence. + +## Acceptance matrix + +| ID | Requirement | Baseline | Evidence required to pass | +| --- | --- | --- | --- | +| SS-01 | ADR-S1 explicitly accepts SQLite WAL, one writer, local filesystem only | PASS | Connection-policy and private-path tests. | +| SS-02 | Task event, projection, idempotency, and Outbox commit atomically | PASS | Duplicate Delivery ID rolls the projection/event/receipt transaction back. | +| SS-03 | Schema migrations are checksummed, fail closed, backed up, and restorable | PARTIAL | Checksum/newer-schema/quick-check pass; online backup and restore fixture remain. | +| SS-04 | Private path, no-follow, ownership, and file-type checks protect all SQLite companion files | PARTIAL | Absolute/private/path-component tests pass; race-free descriptor-relative open and ownership checks remain. | +| SS-05 | Event revisions and identity parents are enforced by database constraints | PARTIAL | Strict DDL enforces event ID, `(task_id, revision)`, and available foreign keys; not every parent is a DB row yet. | +| SS-06 | Unknown execution outcome never auto-replays unsafe side effects | Missing | Crash-boundary reconciliation tests | +| SS-07 | ADR-S2 gives one `RuntimeSupervisor` all Agent child ownership | PARTIAL | Supervisor first slice and owned tests are separately verified; daemon ownership migration remains. | +| SS-08 | Shell owns native PTY only after migration; bridges own no process handles | Missing | Ownership inventory and compile/API review | +| SS-09 | Every spawn has process-group cleanup, bounded I/O, reap, and generation fencing | PARTIAL | Supervisor process cleanup and bounded I/O are separately tested; generation fencing remains. | +| SS-10 | Restart backoff and circuit-open health prevent crash loops | Missing | Deterministic clock/restart-budget tests | +| SS-11 | Daemon restart fences bindings, reclaims leases, and reconciles executions | Missing | End-to-end restart fixtures | +| SS-12 | Session, audit, evidence, and Task stores remain separate | PASS | New Gateway schema does not replace SessionStore/audit/evidence. | +| SS-13 | Bilingual documents, links, and commands are equivalent | PASS | Reciprocal links and implementation evidence are mirrored. | + +`PARTIAL` records a verified slice, not complete supervisor or storage exit. + +## Required fixtures and artifacts + +```text +fixtures/gateway-storage/v1/ + schema.sql + migrations/ + 0001_initial.sql + task-command-atomicity.json + outbox-reclaim.json + execution-outcome-unknown.json + migration-checksums.json + corrupt/ + newer-schema.db + invalid-foreign-key.db + truncated-wal.db +fixtures/runtime-supervisor/v1/ + fake-core-normal + fake-acp-normal + malformed-initialize + oversized-line + stderr-flood + close-stdout + ignore-term + spawn-grandchild + crash-loop +``` + +Required operational artifacts: + +- accepted ADR-S1 and ADR-S2; +- schema diagram and migration compatibility table; +- state-path and file-permission specification; +- backup/restore runbook with verification result; +- disk-full, corruption, stuck WAL, and crash-loop runbooks; +- process ownership inventory proving every child has one owner; +- supervisor transition and shutdown traces from deterministic fixtures. + +These artifacts are absent on the baseline. + +## Required validation commands + +Final package names may follow the implementation scaffold, but acceptance +must record equivalent targeted commands and exact counts: + +```bash +cargo test --package cosh-gateway storage +cargo test --package cosh-gateway --test storage_faults +cargo test --package cosh-gateway runtime_supervisor +cargo test --package cosh-gateway --test supervisor_process_tree -- --test-threads=1 +cargo test --package cosh-shell --test protocol +cargo test --package cosh-shell --test shell_host -- --test-threads=4 +``` + +The Shell targets validate that migration did not regress current protocol and +PTY ownership. They are future implementation gates, not commands run for this +documentation change. + +## Mandatory failure scenarios + +| Scenario | Required outcome | +| --- | --- | +| Crash before Task transaction commit | No event, projection, or Outbox partial state | +| Crash after commit before dispatch | Outbox replays with the same Delivery ID | +| Crash after Permit consume before result | Execution becomes `outcome_unknown`; no unsafe automatic replay | +| Database newer than binary | Startup fails without mutation | +| Migration checksum mismatch | Startup fails and preserves backup/source database | +| WAL or disk full | Admission stops with stable degraded health; no false success | +| Runtime emits after replacement | Generation fence rejects Task mutation | +| Child ignores protocol cancel and TERM | Process group receives KILL and all descendants are reaped | +| Child floods stderr or sends huge frame | Memory remains bounded; Runtime fails with a safe code | +| Daemon shuts down with active Task | Durable state remains explainable; no orphan Agent Runtime child | + +## Remaining implementation + +- No online backup/restore, checkpoint/disk health, corruption quarantine, or operator procedure. +- No Outbox lease/dispatch/ack loop, Run lease, or uncertain execution reconciliation. +- Current path checks are fail-closed but are not yet descriptor-relative and race-free across open. +- A `RuntimeSupervisor` first slice exists with 18 owned tests; Gateway daemon integration, + restart policy, and generation fencing remain. +- cosh-core and provider child ownership is still Shell-local for interactive + use. +- Library tests can launch a fake ACP child through an `AcpV1RuntimeBridge` + that embeds the sole `RuntimeSupervisor`; no installed entrypoint, live + adapter evidence, restart ownership, or daemon integration exists. + +## Exit criteria + +G0/implementation acceptance requires: + +1. SS-01 through SS-13 pass on an exact recorded commit. +2. ADR-S1/S2 and remaining schema-affecting decisions are approved. +3. Every mandatory failure scenario has automated evidence. +4. Backup restoration is tested against the exact migration set. +5. Process-tree tests prove no direct child, grandchild, reader, or writer task + leaks after cancellation and shutdown. +6. Restart recovery produces deterministic Task, Outbox, Runtime binding, and + uncertain Execution states. +7. Existing SessionStore and audit fixtures remain green and unmigrated. +8. No privileged OS mutation, real provider, or ECS result is claimed unless + separately requested and recorded. + +## Validation record + +- Reciprocal English/Chinese links are present. +- ADR decisions, schema draft, failure matrix, commands, and fixtures align + across languages. +- Relative links resolve from this module directory. +- Markdown whitespace and diff hygiene were checked. +- Targeted storage, Task, and Runtime tests are recorded above. Full workspace, + live-system, provider, privileged, and ECS validation was intentionally not run. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance_zh.md new file mode 100644 index 0000000000..137c19c332 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/acceptance_zh.md @@ -0,0 +1,172 @@ +# Phase 0 Storage and Supervision 验收基线 + +[English](acceptance.md) | [设计](design_zh.md) | +[规划集](../../README_zh.md) + +## 基线结论 + +**ADR 方向已用于规划;实现 readiness 未通过。** 已审计源码为 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。 + +基线已经有安全的 provider-session file persistence 与多个成熟的 process-tree +cleanup path,但没有 SQLite dependency、Gateway Task store、Outbox、Runtime +Supervisor、daemon recovery 或 generation fencing。 + +## 首个 ADR-S1 实现结果 + +**Storage 结果:首个切片已验证;Storage Exit 尚未接受。** 当前工作树候选已实现 Task transaction 与 +local SQLite connection policy。Runtime supervision 单独验收,其最终状态由 root integration report 负责。 + +2026-08-13 记录: + +- `cargo test --locked --package cosh-gateway storage --no-fail-fast` 通过 14/14。 +- `cargo test --locked --package cosh-gateway task::aggregate --no-fail-fast` 通过 6/6。 +- `cargo clippy --locked --package cosh-gateway --lib -- -D warnings` 通过。 +- Automated evidence 覆盖 WAL/FULL/foreign-key policy、actor 与 revision substitution、Task/Event/receipt/ + Outbox atomic rollback、checksummed/newer-schema failure、确定性 reopen recovery、causation row、relative + path、不会 chmod 的 insecure parent,以及 intermediate/final symlink。 + +结果口径中,`PASS` 表示完整可复现证据;`PARTIAL` 表示已有验证切片但仍有明确缺口; +`NOT IMPLEMENTED` 或“缺失”表示没有 production path;`BLOCKED` 表示指定依赖阻止验证。 + +## 已审计证据 + +| 来源/符号 | 已核实事实 | +| --- | --- | +| [`SessionStore::persist`](../../../../../crates/cosh-core/src/session/store.rs#L125) | 对单个 provider-session aggregate 使用 validation、lock、generation conflict detection、redaction、bound 与 atomic file commit | +| [`ScopedStorage`](../../../../../crates/cosh-core/src/session/scoped.rs#L27) | 使用 private permission、descriptor-relative operation、no-follow open 与 temporary-file cleanup | +| [`CoshCoreService::new`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs#L106) | Shell 启动拥有 persistent cosh-core process state 的 worker | +| [`service_loop`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs#L283) | Shell 根据 per-turn state reset 或 shutdown Core child | +| [`spawn_provider_child`](../../../../../crates/cosh-shell/src/adapter/process.rs#L66) | Provider process 使用 new session、piped I/O 与 bounded retry | +| [`run_provider_process_loop`](../../../../../crates/cosh-shell/src/adapter/process.rs#L190) | Shell 已有 watchdog、bounded stderr、cancellation escalation 与 reap | +| [`output_with_timeout`](../../../../../crates/cosh-core/src/process.rs#L72) | Core helper subprocess cleanup 覆盖 timeout 与 caller cancellation | +| [`Cargo.toml`](../../../../../Cargo.toml) | 没有声明 SQLite dependency | + +没有运行 provider、ECS、privileged 或 live process test。上述命令是首个实现切片的 local targeted test; +历史基线本身仍是 documentation evidence。 + +## 验收矩阵 + +| ID | 要求 | 基线 | 通过所需证据 | +| --- | --- | --- | --- | +| SS-01 | ADR-S1 明确接受 SQLite WAL、single writer 与 local filesystem only | PASS | Connection-policy 与 private-path test。 | +| SS-02 | Task event、projection、idempotency 与 Outbox atomic commit | PASS | 重复 Delivery ID 使 projection/event/receipt transaction 完整 rollback。 | +| SS-03 | Schema migration checksummed、fail closed、可 backup/restore | PARTIAL | Checksum/newer-schema/quick-check 已通过;online backup 与 restore fixture 待补。 | +| SS-04 | Private path、no-follow、ownership 与 file-type check 保护所有 SQLite companion file | PARTIAL | Absolute/private/path-component test 已通过;race-free descriptor-relative open 与 ownership check 待补。 | +| SS-05 | Event revision 与 identity parent 由 database constraint 执行 | PARTIAL | Strict DDL 强制 event ID、`(task_id, revision)` 与已有 foreign key;并非每类 parent 都已有 DB row。 | +| SS-06 | Unknown execution outcome 不会 auto-replay unsafe side effect | 缺失 | Crash-boundary reconciliation test | +| SS-07 | ADR-S2 把全部 Agent child ownership 交给一个 `RuntimeSupervisor` | PARTIAL | Supervisor 首个切片与 owned test 已单独验证;daemon ownership migration 待补。 | +| SS-08 | 迁移后 Shell 只拥有 native PTY;bridge 不拥有 process handle | 缺失 | Ownership inventory 与 compile/API review | +| SS-09 | 每次 spawn 都有 process-group cleanup、bounded I/O、reap 与 generation fencing | PARTIAL | Supervisor process cleanup 与 bounded I/O 已单独测试;generation fencing 待补。 | +| SS-10 | Restart backoff 与 circuit-open health 防止 crash loop | 缺失 | Deterministic clock/restart-budget test | +| SS-11 | Daemon restart fence binding、reclaim lease 并 reconcile execution | 缺失 | End-to-end restart fixture | +| SS-12 | Session、audit、evidence 与 Task store 保持分离 | PASS | 新 Gateway schema 不替换 SessionStore/audit/evidence。 | +| SS-13 | 双语文档、链接与命令等价 | PASS | Reciprocal link 与 implementation evidence 已镜像。 | + +`PARTIAL` 表示已经验证一个切片,不表示完整 supervisor 或 storage exit。 + +## 必要 Fixture 与 Artifact + +```text +fixtures/gateway-storage/v1/ + schema.sql + migrations/ + 0001_initial.sql + task-command-atomicity.json + outbox-reclaim.json + execution-outcome-unknown.json + migration-checksums.json + corrupt/ + newer-schema.db + invalid-foreign-key.db + truncated-wal.db +fixtures/runtime-supervisor/v1/ + fake-core-normal + fake-acp-normal + malformed-initialize + oversized-line + stderr-flood + close-stdout + ignore-term + spawn-grandchild + crash-loop +``` + +必要 operational artifact: + +- Accepted ADR-S1 与 ADR-S2; +- Schema diagram 与 migration compatibility table; +- State-path 与 file-permission specification; +- 包含验证结果的 backup/restore runbook; +- Disk-full、corruption、stuck WAL 与 crash-loop runbook; +- 证明每个 child 只有一个 owner 的 process ownership inventory; +- Deterministic fixture 产生的 supervisor transition 与 shutdown trace。 + +基线中不存在这些 artifact。 + +## 必要验证命令 + +最终 package 名可遵循 implementation scaffold,但验收必须记录下列等价 targeted +command 与准确 count: + +```bash +cargo test --package cosh-gateway storage +cargo test --package cosh-gateway --test storage_faults +cargo test --package cosh-gateway runtime_supervisor +cargo test --package cosh-gateway --test supervisor_process_tree -- --test-threads=1 +cargo test --package cosh-shell --test protocol +cargo test --package cosh-shell --test shell_host -- --test-threads=4 +``` + +Shell target 验证迁移没有破坏当前 protocol 与 PTY ownership。它们是未来实现 gate, +不是本次文档变更已运行的命令。 + +## 必测 Failure Scenario + +| 场景 | 必需结果 | +| --- | --- | +| Task transaction commit 前 crash | 不存在 event、projection 或 Outbox partial state | +| Commit 后、dispatch 前 crash | Outbox 用同一 Delivery ID replay | +| Permit consume 后、result 前 crash | Execution 变为 `outcome_unknown`,不自动重复 unsafe execution | +| Database schema 比 binary 更新 | Startup 在不 mutation 的前提下失败 | +| Migration checksum mismatch | Startup 失败并保留 backup/source database | +| WAL 或 disk full | Admission 停止并显示 stable degraded health,不产生 false success | +| Runtime replacement 后继续输出 | Generation fence 拒绝 Task mutation | +| Child 忽略 protocol cancel 与 TERM | Process group 收到 KILL,所有 descendant 被 reaped | +| Child flooding stderr 或 huge frame | Memory 保持 bounded;Runtime 用 safe code fail | +| Daemon 在 active Task 中 shutdown | Durable state 可解释;没有 orphan Agent Runtime child | + +## 剩余实现项 + +- 没有 online backup/restore、checkpoint/disk health、corruption quarantine 或 operator procedure。 +- 没有 Outbox lease/dispatch/ack loop、Run lease 或 uncertain execution reconciliation。 +- 当前 path check 会 fail closed,但尚未做到 descriptor-relative 与跨 open 的 race-free。 +- `RuntimeSupervisor` 首个切片已有 18 个 owned test;Gateway daemon integration、restart policy 与 + generation fencing 待补。 +- Interactive 使用中的 cosh-core 与 provider child ownership 仍然在 Shell 内。 +- Library test 可以通过内嵌唯一 `RuntimeSupervisor` 的 `AcpV1RuntimeBridge` 启动 fake ACP + child;仍没有已安装 entrypoint、live adapter 证据、restart ownership 或 daemon integration。 + +## Exit Criteria + +G0/实现验收要求: + +1. SS-01 至 SS-13 在一个准确记录的 commit 上通过。 +2. ADR-S1/S2 与其余影响 schema 的决策已批准。 +3. 每个 mandatory failure scenario 都有 automated evidence。 +4. Backup restoration 针对准确 migration set 测试通过。 +5. Process-tree test 证明 cancellation/shutdown 后不泄漏 direct child、grandchild、 + reader 或 writer task。 +6. Restart recovery 得到确定性的 Task、Outbox、Runtime binding 与 uncertain + Execution state。 +7. 现有 SessionStore 与 audit fixture 保持通过且不迁移。 +8. 除非另行明确请求和记录,不宣称 privileged OS mutation、real provider 或 ECS 结果。 + +## 验证记录 + +- 已提供中英文 reciprocal link。 +- ADR decision、schema draft、failure matrix、command 与 fixture 在两种语言中一致。 +- Relative link 从当前 module directory 可解析。 +- 已检查 Markdown whitespace 与 diff hygiene。 +- 上文记录了 targeted Storage、Task 与 Runtime test;本次没有运行 full workspace、 + live-system、provider、privileged 或 ECS validation。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design.md new file mode 100644 index 0000000000..c58efc0878 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design.md @@ -0,0 +1,443 @@ +# Phase 0 Storage and Supervision Design + +[中文版](design_zh.md) | [Acceptance baseline](acceptance.md) | +[Planning set](../../README.md) + +## Status and decisions + +- Baseline: `up/main` at `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Status: ADR-S1 first storage slice implemented; remaining storage operations and ADR-S2 are + tracked by their acceptance evidence + +This module makes two architecture decisions: + +1. **ADR-S1:** use an embedded SQLite database in WAL mode, with one local + application writer, for Task events, projections, idempotency, approvals, + permits, executions, Runtime bindings, and Outbox delivery. +2. **ADR-S2:** make one Gateway `RuntimeSupervisor` the sole owner of every + Agent Runtime child process, including cosh-core and ACP Agents. Shell keeps + ownership of its interactive PTY process only. + +These decisions do not merge provider conversation persistence, audit +segments, or terminal evidence into the Task database. + +### ADR-S1 implementation note + +The current candidate adds `cosh-gateway::storage` with SQLite WAL, +`synchronous=FULL`, foreign keys, `trusted_schema=OFF`, a five-second busy timeout, strict tables, +and one private connection exposed mutably only through `&mut SqliteTaskStore`. It requires an +absolute database path, creates missing dedicated directories as `0700` and the database as +`0600`, rejects relative paths, insecure existing parents, non-regular files, and symlinks in any +existing path component, and validates existing WAL/SHM companions before and after open. + +The first schema atomically owns Tasks, Task events, actor-scoped command receipts, and Outbox +intents. Checksummed migrations, newer-schema refusal, `quick_check`, deterministic event recovery, +and full transaction rollback have automated evidence. Online backup/restore, checkpoint health, +disk-full injection, race-free descriptor-relative open, Outbox leases, and execution +reconciliation remain required before storage exit. + +## Goals + +- Atomically commit command deduplication, Task events, projections, and + Outbox work. +- Recover Task state after daemon or host restart without treating an Agent + process as the source of truth. +- Fence output from crashed or replaced Runtime generations. +- Give every child process exactly one owner responsible for spawn, pipes, + cancellation, escalation, reap, resource bounds, and diagnostics. +- Preserve existing provider-session files and audit storage during migration. +- Support a local-first installation without requiring an external database. + +## Non-goals + +- Distributed consensus, active-active Gateway replicas, or a network-shared + SQLite file. +- Storing model transcripts, raw terminal output, secrets, or provider stderr + in Task events. +- Guaranteeing replay safety for an arbitrary OS side effect. Unknown + execution outcomes require reconciliation or user approval. +- Supervising the user's native Shell jobs from the Gateway. +- Replacing existing session persistence, audit JSONL, or ws-ckpt protocols. +- Adopting the draft ACP Streamable HTTP transport in Phases 0-2. + +## Current-source evidence + +| Evidence | Verified baseline behavior | Gap for the target architecture | +| --- | --- | --- | +| [`SessionStore`](../../../../../crates/cosh-core/src/session/store.rs#L40) | Workspace-scoped provider sessions use versioned envelopes, generation checks, locks, and atomic commits | It is not a multi-aggregate Task/Event/Outbox transaction store | +| [`ScopedStorage`](../../../../../crates/cosh-core/src/session/scoped.rs#L27) | Descriptor-relative access, `0700` directories, `0600` files, no-follow opens, and atomic rename harden session files | Equivalent hardening must wrap database creation and backup paths | +| [`PersistedSession`](../../../../../crates/cosh-core/src/session.rs#L83) | Provider transcript and compaction projection are durable | Provider history remains a separate data class from Task state | +| [`CoshCoreService`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs#L47) | Shell owns one persistent core process, its worker thread, cancellation, and restart/reset decisions | Ownership is Shell-local and cannot serve detached Web/channel Tasks | +| [`spawn_provider_child`](../../../../../crates/cosh-shell/src/adapter/process.rs#L66) | Provider children get a separate session/process group, bounded stderr, watchdogs, TERM/KILL, and reap | Logic is split across Shell adapters and is not a durable Runtime supervisor | +| [`output_with_timeout`](../../../../../crates/cosh-core/src/process.rs#L72) | Core also has process-group cleanup for bounded helper subprocesses | There is no single owner for Agent Runtime lifecycle | +| [`Cargo.toml`](../../../../../Cargo.toml) | Candidate declares the workspace SQLite client used by `cosh-gateway` | The focused store slice exists; full ADR-S1 exit evidence remains incomplete | +| [Unified audit design](../../../../../docs/design/audit-log.md) | Audit uses per-process JSONL segments with distinct durability and retention semantics | Audit must not be silently redirected to Task SQLite | + +## Data-class boundaries + +| Data class | Owner and store | Why separate | +| --- | --- | --- | +| Task lifecycle | Gateway SQLite | Transactional command/event/projection/Outbox invariants | +| Provider conversation | Existing cosh-core `SessionStore` initially | Model transcript, workspace resume, compaction, and provider compatibility | +| Audit | Existing versioned per-process segments | Append-only operational record, independent failure policy and retention | +| Terminal evidence | Shell/evidence owners | Potentially large, short-lived, and referenced by opaque IDs | +| Runtime diagnostics | Supervisor bounded memory plus redacted audit references | Stderr and protocol failures must not enter domain events raw | + +Later consolidation requires a separate migration ADR. Phase 1 may reference a +provider session from a Runtime binding but does not copy its messages. + +## ADR-S1: SQLite WAL Task store + +### Decision + +The first Gateway uses a private local database, resolved in this order: + +```text +$COSH_GATEWAY_STATE_DIR/state.db +$XDG_STATE_HOME/cosh/gateway/state.db +$HOME/.local/state/cosh/gateway/state.db +``` + +Parent directories are `0700`; database, WAL, shared-memory, backup, and +migration files are private to the effective user. Opens reject symlinks and +non-regular files. Phase 1 supports local filesystems only. A network or shared +filesystem is unsupported and must fail startup validation rather than degrade +silently. + +The database is configured on every connection with equivalent policy: + +```sql +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA synchronous = FULL; +PRAGMA busy_timeout = 5000; +PRAGMA trusted_schema = OFF; +``` + +`synchronous = FULL` is chosen for Task admission, approval, permit, execution, +and Outbox durability. A measured relaxation to `NORMAL` requires an ADR and a +documented loss window. WAL checkpoint policy is bounded and observable; a +checkpoint failure degrades health and never deletes the WAL. + +### One-writer model + +- One bounded Gateway writer task owns the write connection. +- All state-changing commands enter this queue after authentication and size + validation. +- The writer uses `BEGIN IMMEDIATE` and short transactions. +- Read-only projection queries use bounded reader connections and never start + a write transaction. +- No bridge, presenter, HTTP handler, Shell attachment, or executor holds a + database connection or writes a table directly. +- Queue saturation returns a stable overload error before admission; callers + may retry with the same idempotency key. + +The single writer is an application ownership rule, not a claim that SQLite +cannot support several writers. It makes ordering, backpressure, migration, +and failure semantics explicit for a local control plane. + +### Schema ownership draft + +```text +schema_migrations(version, checksum, applied_at_ms) +gateway_meta(key, value) +actors(actor_id, issuer, subject_digest, assurance, status, ...) +commands(command_id, actor_id, idempotency_key, payload_digest, + accepted_at_ms, result_event_id, ...) +tasks(task_id, owner_actor_id, target_ref, revision, state, ...) +task_events(event_id, task_id, revision, event_type, schema_version, + payload_json, occurred_at_ms, causation_message_id) +runs(run_id, task_id, attempt, state, terminal_event_id, ...) +external_refs(external_ref_id, kind, authority, scope_digest, + value_ciphertext_or_value, value_digest) +runtime_instances(runtime_instance_id, generation, launch_digest, + state, last_exit_code, ...) +runtime_bindings(binding_id, task_id, run_id, runtime_instance_id, + runtime_generation, external_ref_id, state, ...) +approvals(approval_id, task_id, run_id, request_digest, state, ...) +permits(permit_id, approval_id, task_id, run_id, target_digest, + operation_digest, expires_at_ms, consumed_by_execution_id, ...) +executions(execution_id, permit_id, state, idempotency_scope, + result_digest, evidence_ref, ...) +outbox(delivery_id, event_id, sink_kind, sink_ref_digest, state, + attempt, next_attempt_at_ms, lease_owner, lease_expires_at_ms, ...) +``` + +`task_events` has unique `(task_id, revision)` and immutable rows. Projection +tables are updated in the same transaction and can be rebuilt from events plus +explicitly versioned migration logic. Event payloads use the frozen contract +schema and are bounded before serialization. + +### Transaction contracts + +#### Command admission + +One transaction: + +1. insert or verify the scoped idempotency row; +2. read and compare Task revision; +3. append one or more Task events; +4. update Task/Run/Approval/Execution projection rows; +5. enqueue presentation and Runtime dispatch intents in Outbox; +6. commit, then wake workers. + +No Runtime call or OS side effect occurs inside the database transaction. + +#### Execution boundary + +1. Atomically verify and consume a single-use Permit and create + `Execution(state=starting)`. +2. Execute outside the transaction through `ExecutionTargetPort`. +3. Persist the terminal result and Task event in one transaction. +4. If the process or host crashes after step 1, recovery marks the Execution + `outcome_unknown`; it does not repeat a non-proven-idempotent mutation. + +#### Outbox delivery + +Workers lease bounded batches. A delivered sink acknowledgement atomically +marks the row delivered. Lease expiry allows retry with the same `DeliveryId`. +Consumers must deduplicate by Delivery ID or accept at-least-once delivery. + +### Migration and recovery + +- Migrations are ordered, checksum-pinned, transactional when SQLite permits, + and applied only by the writer owner before serving traffic. +- A binary refuses a database with a newer schema version. +- Startup runs bounded metadata validation and `quick_check`; full integrity + checks are an explicit maintenance operation. +- Before destructive migrations, create a verified SQLite online backup in a + private sibling path and record the restore procedure. +- A failed migration keeps the daemon unavailable; no partial read-only mode + may approve or execute work. +- On restart, in-flight Runtime bindings are fenced, expired Outbox leases are + reclaimed, and uncertain executions require reconciliation. + +### Alternatives considered + +| Alternative | Strength | Why not selected for Phase 1 | +| --- | --- | --- | +| Append-only JSONL plus rebuilt projections | Simple inspection; aligns with audit segments | Atomic event/projection/idempotency/Outbox updates and indexed concurrency require substantial custom recovery | +| One atomic JSON file per Task | Reuses SessionStore patterns | Cross-Task queries, Outbox leasing, uniqueness, and multi-entity transactions become lock choreography | +| Existing `SessionStore` | Mature secure file handling | Its aggregate is provider conversation history, not Task/Approval/Execution state | +| Embedded KV (`redb`, `sled`, RocksDB) | Fast key-value access | Adds custom schema/index/transaction tooling and weaker operational inspectability for this relational workload | +| Rollback-journal SQLite | Simpler file set | Readers block more often during writes; WAL better fits attachment replay and dashboards | +| External PostgreSQL | Strong multi-node operations | Violates zero-dependency local-first installation and is unnecessary before multi-replica control plane requirements | +| In-memory state with audit replay | Small initial implementation | Loses durable idempotency and confuses audit with the authoritative event store | + +SQLite WAL does not solve distributed ownership. If a future architecture +requires active-active Gateways or network storage, migrate through a new +storage-port implementation and data migration ADR. + +## ADR-S2: Runtime Supervisor ownership + +### Decision + +`RuntimeSupervisor` is the sole implementation allowed to manipulate handles +for cosh-core and ACP Agent child processes. A bridge owns protocol codecs and +session semantics and may compose exactly one supervisor, as the current +`AcpV1RuntimeBridge` does. Spawn, signal, wait, and reap behavior must still be +delegated to that embedded supervisor; no second lifecycle owner may retain a +child handle. + +| Process/resource | Sole owner | Notes | +| --- | --- | --- | +| Native bash/zsh PTY and foreground jobs | `cosh-shell` Shell host | Preserves terminal job control and attach experience | +| cosh-core Agent Runtime | Gateway `RuntimeSupervisor` after Phase 1 migration | Current Shell owner remains only during compatibility fallback | +| ACP Agent stdio process | Gateway `RuntimeSupervisor` | Phase 2 local stdio only | +| Short-lived core helper subprocess | Its existing scoped core owner | Not an Agent Runtime; must retain process-group cleanup | +| OS operation execution | `ExecutionTargetPort` implementation | Governed by Permit; not a Runtime bridge child | + +### Typed supervision contract + +```rust +struct RuntimeLaunchSpec { + kind: RuntimeKind, + executable: TrustedExecutable, + args: Vec, + cwd: CanonicalPath, + env: AllowlistedEnvironment, + protocol: RuntimeProtocol, + resource_profile: ResourceProfile, + restart_policy: RestartPolicy, +} + +struct SupervisedRuntimeRef { + instance_id: RuntimeInstanceId, + generation: u64, + launch_digest: Digest, +} + +enum SupervisorCommand { + EnsureRunning { spec: RuntimeLaunchSpec }, + OpenChannel { runtime: SupervisedRuntimeRef }, + CancelRun { runtime: SupervisedRuntimeRef, run_id: RunId }, + Stop { runtime: SupervisedRuntimeRef, reason: StopReason }, +} + +enum SupervisorEvent { + Started { runtime: SupervisedRuntimeRef, pid_observed: u32 }, + Ready { runtime: SupervisedRuntimeRef }, + ProtocolFailed { runtime: SupervisedRuntimeRef, code: RuntimeErrorCode }, + Exited { runtime: SupervisedRuntimeRef, exit: BoundedExit }, + RestartScheduled { previous: SupervisedRuntimeRef, backoff_ms: u64 }, +} +``` + +PID is diagnostic only. `RuntimeInstanceId + generation` is the fencing +identity. Secrets are referenced from a credential provider and materialized +only into the child launch environment; they are absent from launch digests, +events, and diagnostics. + +### State machine + +```text +Absent -> Starting -> Initializing -> Ready <-> Busy + | | | | + +-------------+-----------+--------+-> Stopping -> Exited + \----------> Failed -> Backoff -> Starting(new generation) +``` + +- Every spawn increments generation before any event can be admitted. +- `Ready` requires protocol initialization and version/capability validation. +- Bridges may multiplex sessions only when the negotiated protocol and + scheduler policy permit it. One ACP connection can support several sessions, + but Phase 2 does not assume every Agent safely handles concurrent prompts. +- cosh-core process reuse continues to respect its current approval mode, + workspace scope, and provider-session binding constraints. +- Unexpected exit makes every binding for that generation stale. Task state + remains durable and decides resume, retry, or user intervention. +- Restart budgets use bounded exponential backoff and a circuit-open terminal + health state; crash loops never spin indefinitely. + +### Spawn and I/O safety + +- Resolve executables through trusted installation/configuration, not user + prompt text. Record an executable/argument digest. +- Canonicalize cwd and validate target access before spawn. +- Start the child in its own process group/session; do not use PID as a durable + identity. +- Use piped stdin/stdout for protocols, continuous bounded stderr draining, + maximum line/message sizes, bounded queues, and explicit backpressure. +- Protocol stdout must contain protocol frames only. Human logs go to stderr + and are redacted/bounded before diagnostic retention. +- Close-on-exec all unrelated descriptors. Child environment is allowlisted; + Gateway/channel credentials are never inherited by default. +- Register the child as owned only after pipes and process-group setup succeed; + every partial-spawn failure still kills and reaps it. + +### Cancellation and shutdown + +For an active Run: + +1. persist `CancelRequested` in the Task store; +2. bridge sends protocol cancellation when available (`session/cancel` for an + ACP prompt; current core interrupt for cosh-core); +3. wait a bounded protocol grace while accepting allowed terminal updates; +4. close stdin or send shutdown when the connection is being retired; +5. send `SIGTERM` to the process group; +6. after a bounded grace, send `SIGKILL` to the process group and direct child; +7. reap the child and reader tasks; +8. persist the observed cancellation/exit outcome with the same Runtime + generation. + +Daemon shutdown stops admissions, durably records pending cancellation or +handoff state, drains Outbox within a deadline, terminates Runtime children, +and closes SQLite last. Shell PTY shutdown remains owned by Shell. + +### Restart and orphan policy + +- Gateway child processes are not intentionally orphaned across daemon exit. +- On daemon restart, durable Runtime instances become `stale`; the supervisor + does not attach to a PID discovered by number alone. +- A future detach/reattach model for Runtime processes requires a brokered + socket, authenticated ownership token, and separate ADR. +- Tasks with no side effect may resume/retry according to bridge capability. + Executions in `starting` or `running` require target-specific reconciliation + before retry. + +## Error and security boundaries + +- Storage unavailable, migration failure, corrupt critical rows, or schema + mismatch blocks new governed execution. +- Read-only UI may expose an explicit degraded state only when doing so does + not mutate leases or acknowledge delivery. +- Database errors carry stable safe codes; SQL, paths containing private data, + payload JSON, and secrets are not returned to channels. +- Supervisor error events contain bounded exit class and redacted stderr + reference, never raw streams. +- A bridge cannot bypass Broker authorization through terminal or filesystem + callbacks. +- Runtime generation and launch digest are verified on every event and + permission response. +- Database backup/export requires explicit authorization and private output + permissions. + +## Compatibility and migration + +1. Add SQLite store and Runtime Supervisor behind new ports without changing + current Shell behavior. +2. Implement CoshCore Bridge under Supervisor control; keep current Shell-local + service as a feature-gated fallback. +3. Persist Task-to-existing-provider-session bindings; do not import transcript + messages into SQLite. +4. Switch Shell to Gateway attachment only after Phase 1 storage/restart gates + pass. +5. Add ACP Runtime under the same Supervisor in Phase 2. +6. Retire duplicate Shell Agent process ownership after the compatibility + window, while Shell retains native PTY ownership. + +Rollback before final cutover disables Gateway admission and returns to the +current Shell-local path. Database files are preserved for forward recovery; +rollback code must not downgrade or rewrite a newer schema. + +## Dependencies + +- [Protocol contracts](../protocol-contracts/design.md) defines stored event + and supervisor port payloads. +- [Identity and correlation](../identity-correlation/design.md) defines + foreign-key identity and generation fencing. +- Phase 1 Task Plane implements the writer and reducer. +- Phase 1 CoshCore Bridge is the first supervised Runtime. +- Phase 2 ACP bridge consumes supervised stdio. + +## Implementation tasks + +1. Record ADR-S1 and ADR-S2 acceptance, including local-filesystem support. +2. Select a maintained SQLite Rust crate under workspace dependency policy. +3. Implement secure state-path creation, connection policy, migrations, writer + queue, readers, health, backup, and restore tooling. +4. Implement schema 1, atomic command/event/projection/Outbox transactions, + and crash recovery. +5. Implement supervisor state machine, process groups, bounded I/O, generation + fencing, restart budgets, and shutdown ordering. +6. Move CoshCore Bridge process ownership behind the Supervisor. +7. Add fake core and ACP child fixtures for crash, hang, malformed output, + cancellation, and process-tree leakage. +8. Document operational status, backup, restore, corruption, and disk-full + procedures before enabling the Gateway by default. + +## Test strategy + +- SQLite tests cover transaction rollback, unique revisions, foreign keys, + idempotency conflicts, Outbox leases, migration checksums, disk full, + checkpoint failure, corruption, backup, and restore. +- Crash fixtures stop the process after each transaction boundary and verify + replay/reconciliation behavior. +- Concurrency tests saturate writer and reader queues without bypassing the + sole writer. +- Supervisor tests cover partial spawn, invalid initialization, huge lines, + closed pipes, stderr floods, timeout, TERM-ignoring children, grandchildren, + crash loops, shutdown, and stale-generation output. +- No test invokes privileged OS mutation. Process tests use deterministic + fixture programs and temporary directories. + +## Open decisions + +| Decision | Owner | Must close by | +| --- | --- | --- | +| SQLite Rust crate and feature set | Storage owner | First Phase 1 storage PR | +| Exact WAL auto-checkpoint and maximum WAL health thresholds | Storage/SRE owners | Before restart acceptance | +| Encryption mechanism for raw external reference values | Security owner | Schema migration 1 freeze | +| Runtime pool concurrency per Agent implementation | Runtime owner | Bridge-specific acceptance | +| Linux pidfd/subreaper use versus process-group baseline | Runtime owner | Supervisor implementation review | +| Database retention/compaction policy for Task events | Product and storage owners | Before public Gateway rollout | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design_zh.md new file mode 100644 index 0000000000..2c606d48c1 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-0/storage-supervision/design_zh.md @@ -0,0 +1,412 @@ +# Phase 0 Storage and Supervision 设计 + +[English](design.md) | [验收基线](acceptance_zh.md) | +[规划集](../../README_zh.md) + +## 状态与决策 + +- 基线:`up/main` 的 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 状态:ADR-S1 首个 storage 切片已实现;其余 storage operation 与 ADR-S2 由各自验收证据继续跟踪 + +本模块做出两项架构决策: + +1. **ADR-S1:** Task event、projection、idempotency、approval、permit、execution、 + Runtime binding 与 Outbox delivery 使用 WAL mode 的 embedded SQLite,并由一个 + local application writer 写入。 +2. **ADR-S2:** 由一个 Gateway `RuntimeSupervisor` 独占所有 Agent Runtime child + process,包括 cosh-core 与 ACP Agent。Shell 只保留 interactive PTY process + ownership。 + +这些决策不会把 provider conversation persistence、audit segment 或 terminal +evidence 合并进 Task database。 + +### ADR-S1 实现说明 + +当前候选新增 `cosh-gateway::storage`,使用 SQLite WAL、`synchronous=FULL`、foreign key、 +`trusted_schema=OFF`、五秒 busy timeout 与 strict table。单一 private connection 仅通过 +`&mut SqliteTaskStore` 暴露可变访问。它要求 absolute database path,把缺失的专用目录创建为 `0700`、 +database 创建为 `0600`,拒绝 relative path、不安全 existing parent、non-regular file 以及任一已存在 +path component 中的 symlink,并在 open 前后检查既有 WAL/SHM companion。 + +首个 schema 原子拥有 Task、Task event、actor-scoped command receipt 与 Outbox intent。Checksummed +migration、newer-schema refusal、`quick_check`、确定性 event recovery 与完整 transaction rollback 已有 +automated evidence。Online backup/restore、checkpoint health、disk-full injection、race-free +descriptor-relative open、Outbox lease 与 execution reconciliation 仍是 storage exit 前的必需项。 + +## 目标 + +- Atomic commit command deduplication、Task event、projection 与 Outbox work。 +- Daemon 或 host restart 后恢复 Task state,不把 Agent process 当成事实来源。 +- Fence crashed/replaced Runtime generation 的 output。 +- 每个 child process 只有一个 owner,负责 spawn、pipe、cancellation、escalation、 + reap、resource bound 与 diagnostics。 +- 迁移期间保留现有 provider-session file 与 audit storage。 +- 支持不需要 external database 的 local-first installation。 + +## 非目标 + +- Distributed consensus、active-active Gateway replica 或 network-shared SQLite file。 +- 在 Task event 中保存 model transcript、raw terminal output、secret 或 provider stderr。 +- 保证 arbitrary OS side effect 的 replay safety。Unknown execution outcome 需要 + reconciliation 或 user approval。 +- 由 Gateway 监督用户 native Shell job。 +- 替换现有 session persistence、audit JSONL 或 ws-ckpt protocol。 +- 在 Phase 0-2 采用仍处于草案状态的 ACP Streamable HTTP transport。 + +## 当前源码证据 + +| 证据 | 已核实的基线行为 | 目标架构缺口 | +| --- | --- | --- | +| [`SessionStore`](../../../../../crates/cosh-core/src/session/store.rs#L40) | Workspace-scoped provider session 使用 versioned envelope、generation check、lock 与 atomic commit | 它不是 multi-aggregate Task/Event/Outbox transaction store | +| [`ScopedStorage`](../../../../../crates/cosh-core/src/session/scoped.rs#L27) | Descriptor-relative access、`0700` directory、`0600` file、no-follow open 与 atomic rename 强化 session file | Database creation 与 backup path 需要等价保护 | +| [`PersistedSession`](../../../../../crates/cosh-core/src/session.rs#L83) | Provider transcript 与 compaction projection 已持久化 | Provider history 与 Task state 仍是不同 data class | +| [`CoshCoreService`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs#L47) | Shell 拥有一个 persistent Core process、worker thread、cancellation 与 restart/reset decision | Ownership 是 Shell-local,无法承载 detached Web/channel Task | +| [`spawn_provider_child`](../../../../../crates/cosh-shell/src/adapter/process.rs#L66) | Provider child 使用独立 session/process group、bounded stderr、watchdog、TERM/KILL 与 reap | 逻辑分散在 Shell adapter 中,不是 durable Runtime supervisor | +| [`output_with_timeout`](../../../../../crates/cosh-core/src/process.rs#L72) | Core 对 bounded helper subprocess 也有 process-group cleanup | Agent Runtime lifecycle 没有统一 owner | +| [`Cargo.toml`](../../../../../Cargo.toml) | Candidate 已声明 `cosh-gateway` 使用的 workspace SQLite client | focused store slice 已存在;完整 ADR-S1 exit evidence 仍不完整 | +| [Unified audit design](../../../../../docs/design/audit-log.md) | Audit 使用 per-process JSONL segment,拥有独立 durability 与 retention semantics | Audit 不能被静默转入 Task SQLite | + +## Data-class 边界 + +| Data class | Owner 与 store | 分离原因 | +| --- | --- | --- | +| Task lifecycle | Gateway SQLite | Transactional command/event/projection/Outbox invariant | +| Provider conversation | 首版沿用 cosh-core `SessionStore` | Model transcript、workspace resume、compaction 与 provider compatibility | +| Audit | 现有 versioned per-process segment | Append-only operational record,独立 failure policy 与 retention | +| Terminal evidence | Shell/evidence owner | 可能很大、短期存在,并通过 opaque ID 引用 | +| Runtime diagnostics | Supervisor bounded memory 加 redacted audit reference | Stderr 与 protocol failure 不得原样进入 domain event | + +后续 consolidation 需要单独 migration ADR。Phase 1 可以从 Runtime binding 引用 +provider session,但不复制其中 message。 + +## ADR-S1:SQLite WAL Task Store + +### 决策 + +首个 Gateway 使用 private local database,解析顺序如下: + +```text +$COSH_GATEWAY_STATE_DIR/state.db +$XDG_STATE_HOME/cosh/gateway/state.db +$HOME/.local/state/cosh/gateway/state.db +``` + +Parent directory 使用 `0700`;database、WAL、shared-memory、backup 与 migration +file 对 effective user 保持 private。Open 拒绝 symlink 与 non-regular file。 +Phase 1 只支持 local filesystem。Network/shared filesystem 不受支持,必须在启动 +校验时失败,不能静默降级。 + +每个 connection 使用等价 database policy: + +```sql +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA synchronous = FULL; +PRAGMA busy_timeout = 5000; +PRAGMA trusted_schema = OFF; +``` + +Task admission、approval、permit、execution 与 Outbox durability 选择 +`synchronous = FULL`。若通过测量放宽到 `NORMAL`,必须有 ADR 并记录 data-loss +window。WAL checkpoint policy 必须 bounded 且 observable;checkpoint failure +使 health degraded,但绝不能删除 WAL。 + +### 单 Writer Model + +- 一个 bounded Gateway writer task 独占 write connection。 +- 所有 state-changing command 经过 authentication 与 size validation 后进入该 queue。 +- Writer 使用 `BEGIN IMMEDIATE` 与短 transaction。 +- Read-only projection query 使用 bounded reader connection,不启动 write transaction。 +- Bridge、presenter、HTTP handler、Shell attachment 与 executor 都不能持有 database + connection 或直接写 table。 +- Queue saturation 在 admission 前返回 stable overload error;caller 用同一 + idempotency key retry。 + +Single writer 是 application ownership rule,不表示 SQLite 不能支持多个 writer。 +它为 local control plane 明确 ordering、backpressure、migration 与 failure semantics。 + +### Schema Ownership 草案 + +```text +schema_migrations(version, checksum, applied_at_ms) +gateway_meta(key, value) +actors(actor_id, issuer, subject_digest, assurance, status, ...) +commands(command_id, actor_id, idempotency_key, payload_digest, + accepted_at_ms, result_event_id, ...) +tasks(task_id, owner_actor_id, target_ref, revision, state, ...) +task_events(event_id, task_id, revision, event_type, schema_version, + payload_json, occurred_at_ms, causation_message_id) +runs(run_id, task_id, attempt, state, terminal_event_id, ...) +external_refs(external_ref_id, kind, authority, scope_digest, + value_ciphertext_or_value, value_digest) +runtime_instances(runtime_instance_id, generation, launch_digest, + state, last_exit_code, ...) +runtime_bindings(binding_id, task_id, run_id, runtime_instance_id, + runtime_generation, external_ref_id, state, ...) +approvals(approval_id, task_id, run_id, request_digest, state, ...) +permits(permit_id, approval_id, task_id, run_id, target_digest, + operation_digest, expires_at_ms, consumed_by_execution_id, ...) +executions(execution_id, permit_id, state, idempotency_scope, + result_digest, evidence_ref, ...) +outbox(delivery_id, event_id, sink_kind, sink_ref_digest, state, + attempt, next_attempt_at_ms, lease_owner, lease_expires_at_ms, ...) +``` + +`task_events` 对 `(task_id, revision)` 建立 unique constraint,row immutable。 +Projection table 与 event 在同一 transaction 中更新,并可通过 event 与显式 versioned +migration logic 重建。Event payload 使用 frozen contract schema,serialization 前 +必须 bounded。 + +### Transaction Contract + +#### Command admission + +一个 transaction 完成: + +1. Insert 或校验 scoped idempotency row; +2. 读取并比较 Task revision; +3. 追加一个或多个 Task event; +4. 更新 Task/Run/Approval/Execution projection row; +5. 把 presentation 与 Runtime dispatch intent 加入 Outbox; +6. Commit 后唤醒 worker。 + +Database transaction 内不调用 Runtime,也不执行 OS side effect。 + +#### Execution boundary + +1. Atomic verify/consume single-use Permit,并创建 `Execution(state=starting)`。 +2. 在 transaction 外通过 `ExecutionTargetPort` 执行。 +3. 在一个 transaction 中持久化 terminal result 与 Task event。 +4. 如果 process 或 host 在步骤 1 后 crash,recovery 把 Execution 标记为 + `outcome_unknown`;不能重复 non-proven-idempotent mutation。 + +#### Outbox delivery + +Worker lease bounded batch。Delivered sink acknowledgement atomic mark row delivered。 +Lease expiry 允许用同一 `DeliveryId` retry。Consumer 必须按 Delivery ID 去重, +或接受 at-least-once delivery。 + +### Migration 与 Recovery + +- Migration ordered、checksum-pinned,并在 SQLite 允许时保持 transactional;只能由 + writer owner 在开始服务前执行。 +- Binary 拒绝更新 schema version 的 database。 +- 启动时执行 bounded metadata validation 与 `quick_check`;完整 integrity check + 属于显式 maintenance operation。 +- Destructive migration 前,在 private sibling path 创建 verified SQLite online backup, + 并记录 restore procedure。 +- Migration failure 使 daemon unavailable;不能通过 partial read-only mode 批准或 + 执行 work。 +- Restart 时 fence in-flight Runtime binding、reclaim expired Outbox lease, + uncertain execution 等待 reconciliation。 + +### 比较过的替代方案 + +| 替代方案 | 优点 | Phase 1 不选择的原因 | +| --- | --- | --- | +| Append-only JSONL 加 rebuilt projection | 易检查,与 audit segment 一致 | Atomic event/projection/idempotency/Outbox update 与 indexed concurrency 需要大量自研 recovery | +| 每个 Task 一个 atomic JSON file | 可复用 SessionStore pattern | Cross-Task query、Outbox lease、uniqueness 与 multi-entity transaction 会变成复杂 lock choreography | +| 现有 `SessionStore` | Secure file handling 成熟 | Aggregate 是 provider conversation history,不是 Task/Approval/Execution state | +| Embedded KV(`redb`、`sled`、RocksDB) | Key-value access 快 | 对 relational workload 需要自研 schema/index/transaction tooling,operational inspectability 较弱 | +| Rollback-journal SQLite | File set 更简单 | Write 时 reader 更容易 blocked;WAL 更适合 attachment replay 与 dashboard | +| External PostgreSQL | Multi-node operation 强 | 破坏 zero-dependency local-first installation,在 multi-replica 要求出现前没有必要 | +| In-memory state 加 audit replay | 初期实现小 | 丢失 durable idempotency,并把 audit 与 authoritative event store 混淆 | + +SQLite WAL 不解决 distributed ownership。如果未来需要 active-active Gateway 或 network +storage,必须通过新的 storage-port implementation 与 data migration ADR 迁移。 + +## ADR-S2:Runtime Supervisor Ownership + +### 决策 + +`RuntimeSupervisor` 是唯一允许操作 cosh-core 与 ACP Agent child process handle 的 +implementation。Bridge 拥有 protocol codec 与 session semantics,也可以像当前 +`AcpV1RuntimeBridge` 一样组合且仅组合一个 Supervisor。Spawn、signal、wait 与 reap +仍必须委托给该内嵌 Supervisor,任何第二 lifecycle owner 都不能保留 child handle。 + +| Process/resource | Sole owner | 说明 | +| --- | --- | --- | +| Native bash/zsh PTY 与 foreground job | `cosh-shell` Shell host | 保留 terminal job control 与 attachment experience | +| cosh-core Agent Runtime | Phase 1 迁移后的 Gateway `RuntimeSupervisor` | 当前 Shell owner 仅在 compatibility fallback 中保留 | +| ACP Agent stdio process | Gateway `RuntimeSupervisor` | Phase 2 仅 local stdio | +| Short-lived Core helper subprocess | 现有 scoped Core owner | 不是 Agent Runtime,继续使用 process-group cleanup | +| OS operation execution | `ExecutionTargetPort` implementation | 受 Permit 治理,不是 Runtime bridge child | + +### Typed Supervision Contract + +```rust +struct RuntimeLaunchSpec { + kind: RuntimeKind, + executable: TrustedExecutable, + args: Vec, + cwd: CanonicalPath, + env: AllowlistedEnvironment, + protocol: RuntimeProtocol, + resource_profile: ResourceProfile, + restart_policy: RestartPolicy, +} + +struct SupervisedRuntimeRef { + instance_id: RuntimeInstanceId, + generation: u64, + launch_digest: Digest, +} + +enum SupervisorCommand { + EnsureRunning { spec: RuntimeLaunchSpec }, + OpenChannel { runtime: SupervisedRuntimeRef }, + CancelRun { runtime: SupervisedRuntimeRef, run_id: RunId }, + Stop { runtime: SupervisedRuntimeRef, reason: StopReason }, +} + +enum SupervisorEvent { + Started { runtime: SupervisedRuntimeRef, pid_observed: u32 }, + Ready { runtime: SupervisedRuntimeRef }, + ProtocolFailed { runtime: SupervisedRuntimeRef, code: RuntimeErrorCode }, + Exited { runtime: SupervisedRuntimeRef, exit: BoundedExit }, + RestartScheduled { previous: SupervisedRuntimeRef, backoff_ms: u64 }, +} +``` + +PID 只用于 diagnostics。`RuntimeInstanceId + generation` 是 fencing identity。 +Secret 由 credential provider 引用,只在 child launch environment 中 materialize; +launch digest、event 与 diagnostics 都不包含 secret。 + +### State Machine + +```text +Absent -> Starting -> Initializing -> Ready <-> Busy + | | | | + +-------------+-----------+--------+-> Stopping -> Exited + \----------> Failed -> Backoff -> Starting(new generation) +``` + +- 每次 spawn 在任何 event admission 前增加 generation。 +- `Ready` 要求 protocol initialization、version 与 capability validation 通过。 +- 只有 negotiated protocol 与 scheduler policy 允许时,bridge 才能 multiplex session。 + 一个 ACP connection 可支持多个 session,但 Phase 2 不假定所有 Agent 都能安全并发 prompt。 +- cosh-core process reuse 继续遵循当前 approval mode、workspace scope 与 + provider-session binding constraint。 +- Unexpected exit 使对应 generation 的所有 binding stale。Task state 保持 durable, + 并决定 resume、retry 或 user intervention。 +- Restart budget 使用 bounded exponential backoff 与 circuit-open terminal health state; + crash loop 不会无限旋转。 + +### Spawn 与 I/O 安全 + +- Executable 从 trusted installation/configuration 解析,不从 user prompt text 解析。 + 记录 executable/argument digest。 +- Spawn 前 canonicalize cwd 并校验 target access。 +- Child 使用自己的 process group/session;不把 PID 当作 durable identity。 +- Protocol 使用 piped stdin/stdout、持续 bounded stderr drain、最大 line/message size、 + bounded queue 与显式 backpressure。 +- Protocol stdout 只能包含 protocol frame。Human log 写 stderr,并在 diagnostic + retention 前 redacted/bounded。 +- 所有无关 descriptor close-on-exec。Child environment 使用 allowlist,默认不继承 + Gateway/channel credential。 +- Pipe 与 process-group setup 成功后才注册 child ownership;partial-spawn failure + 也必须 kill/reap。 + +### Cancellation 与 Shutdown + +对于 active Run: + +1. 在 Task store 持久化 `CancelRequested`; +2. Bridge 在可用时发送 protocol cancellation。ACP prompt 使用 `session/cancel`, + cosh-core 使用当前 interrupt; +3. 在 bounded protocol grace 内接受允许的 terminal update; +4. Retire connection 时关闭 stdin 或发送 shutdown; +5. 向 process group 发送 `SIGTERM`; +6. Bounded grace 后向 process group 与 direct child 发送 `SIGKILL`; +7. Reap child 与 reader task; +8. 用同一 Runtime generation 持久化 observed cancellation/exit outcome。 + +Daemon shutdown 停止 admission、持久化 pending cancellation/handoff state、在 deadline +内 drain Outbox、终止 Runtime child,最后关闭 SQLite。Shell PTY shutdown 继续由 Shell +拥有。 + +### Restart 与 Orphan Policy + +- Gateway child process 不会在 daemon exit 时故意 orphan。 +- Daemon restart 时 durable Runtime instance 变为 `stale`;Supervisor 不会仅根据 PID + number attach process。 +- 未来 Runtime process detach/reattach 需要 brokered socket、authenticated ownership + token 与单独 ADR。 +- 无副作用 Task 可根据 bridge capability resume/retry。处于 `starting` 或 `running` + 的 Execution 必须经过 target-specific reconciliation 才能 retry。 + +## Error 与安全边界 + +- Storage unavailable、migration failure、critical row corrupt 或 schema mismatch 会阻止 + 新 governed execution。 +- 只有在不修改 lease 或 acknowledgement 时,read-only UI 才能暴露显式 degraded state。 +- Database error 使用 stable safe code;不向渠道返回 SQL、含 private data 的 path、 + payload JSON 或 secret。 +- Supervisor error event 只包含 bounded exit class 与 redacted stderr reference,不包含 + raw stream。 +- Bridge 不能通过 terminal/filesystem callback 绕过 Broker authorization。 +- 每个 event 与 permission response 都校验 Runtime generation 与 launch digest。 +- Database backup/export 需要显式 authorization 与 private output permission。 + +## 兼容与迁移 + +1. 在新 port 后添加 SQLite store 与 Runtime Supervisor,不改变当前 Shell behavior。 +2. 在 Supervisor control 下实现 CoshCore Bridge;把当前 Shell-local service 保留为 + feature-gated fallback。 +3. 持久化 Task 到 existing-provider-session binding,不把 transcript message 导入 SQLite。 +4. Phase 1 storage/restart gate 通过后,才把 Shell 切换为 Gateway attachment。 +5. Phase 2 在同一 Supervisor 下添加 ACP Runtime。 +6. 兼容窗口后移除重复的 Shell Agent process ownership;Shell 继续拥有 native PTY。 + +最终 cutover 前的 rollback 会关闭 Gateway admission 并恢复当前 Shell-local path。 +Database file 保留用于 forward recovery;rollback code 不得 downgrade 或重写更新 schema。 + +## 依赖 + +- [Protocol Contracts](../protocol-contracts/design_zh.md)定义 stored event 与 supervisor + port payload。 +- [Identity and Correlation](../identity-correlation/design_zh.md)定义 foreign-key identity + 与 generation fencing。 +- Phase 1 Task Plane 实现 writer 与 reducer。 +- Phase 1 CoshCore Bridge 是首个 supervised Runtime。 +- Phase 2 ACP bridge 消费 supervised stdio。 + +## 实施任务 + +1. 记录 ADR-S1 与 ADR-S2 的 acceptance,包括 local-filesystem support。 +2. 按 workspace dependency policy 选择 maintained SQLite Rust crate。 +3. 实现 secure state-path creation、connection policy、migration、writer queue、reader、 + health、backup 与 restore tooling。 +4. 实现 schema 1、atomic command/event/projection/Outbox transaction 与 crash recovery。 +5. 实现 supervisor state machine、process group、bounded I/O、generation fencing、 + restart budget 与 shutdown ordering。 +6. 把 CoshCore Bridge process ownership 移到 Supervisor 后。 +7. 添加 fake Core 与 ACP child fixture,覆盖 crash、hang、malformed output、 + cancellation 与 process-tree leakage。 +8. 默认启用 Gateway 前,记录 operational status、backup、restore、corruption 与 + disk-full procedure。 + +## 测试策略 + +- SQLite test 覆盖 transaction rollback、unique revision、foreign key、idempotency + conflict、Outbox lease、migration checksum、disk full、checkpoint failure、 + corruption、backup 与 restore。 +- Crash fixture 在每个 transaction boundary 后停止 process,并验证 replay/ + reconciliation behavior。 +- Concurrency test 饱和 writer/reader queue,不绕过 sole writer。 +- Supervisor test 覆盖 partial spawn、invalid initialization、huge line、closed pipe、 + stderr flood、timeout、忽略 TERM 的 child、grandchild、crash loop、shutdown 与 + stale-generation output。 +- Test 不执行 privileged OS mutation。Process test 使用 deterministic fixture program + 与 temporary directory。 + +## 开放决策 + +| 决策 | Owner | 最晚关闭时间 | +| --- | --- | --- | +| SQLite Rust crate 与 feature set | Storage owner | 第一个 Phase 1 storage PR | +| WAL auto-checkpoint 与 maximum WAL health threshold | Storage/SRE owner | Restart acceptance 前 | +| Raw external reference value 的 encryption mechanism | Security owner | Schema migration 1 freeze 前 | +| 每种 Agent implementation 的 Runtime pool concurrency | Runtime owner | Bridge-specific acceptance 前 | +| Linux pidfd/subreaper 或 process-group baseline | Runtime owner | Supervisor implementation review 前 | +| Task event database retention/compaction policy | Product 与 storage owner | Public Gateway rollout 前 | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance.md new file mode 100644 index 0000000000..0c8e4db678 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance.md @@ -0,0 +1,133 @@ +# ACP v1 Local Runtime MVP Acceptance Report + +[中文版](acceptance_zh.md) | [Design](design.md) | +[Planning set](../../README.md) + +## Result + +**PARTIAL IMPLEMENTATION / NOT ACCEPTED.** The candidate has a strict ACP v1 +codec, supervised stdio bridge, bounded session driver with independent +cancellation, deterministic fake-Agent fixtures, and built-in profile +resolution for `codex-acp` and `claude-agent-acp`. It has no installed COSH +entrypoint, local permission UI/evidence record, or real-adapter evidence. + +This gate is independent from complete G1 and G2 acceptance. Passing it will +prove only the narrow local interoperability outcome defined in the design. + +## Status vocabulary + +| Status | Meaning | +| --- | --- | +| `PASS` | Exact candidate evidence satisfies the whole MVP criterion | +| `PARTIAL` | A bounded source/test slice exists, but the user path or required proof is incomplete | +| `FAIL` | Implemented behavior was exercised and contradicted the criterion | +| `NOT IMPLEMENTED` | The required production surface does not exist | +| `NOT RUN` | The surface exists, but required evidence was not executed | + +## Current evidence + +| Area | Current status | Evidence and gap | +| --- | --- | --- | +| ACP v1 codec | `PARTIAL` | Exact wire v1 initialization, one session, text prompt/update/stop, bounds, and malformed-input handling have focused fixtures; no real adapter run | +| Supervised stdio | `PARTIAL` | The bridge composes one supervisor and a bounded driver with deadlines/backpressure; broader race and process-tree fixtures remain | +| Runtime profiles | `PARTIAL` | Built-in resolver pins `codex-acp` and `claude-agent-acp`, canonical executable/workspace, fixed args, and environment allowlists; no installed user entrypoint | +| Streaming | `PARTIAL` | A bounded driver delivers decoded observations in receive order and fails closed on saturation; local sequence and presentation are incomplete | +| Cancellation | `PARTIAL` | Independent control reaches a silent Agent, settles pending permission callbacks, and reaps the process; wider race coverage remains | +| Permission correlation | `PARTIAL` | Offered request/option IDs are checked, durable options are rejected, and responses are single-use; no local user decision surface or evidence record | +| Unsupported callbacks | `PARTIAL` | Fake fs request receives correlated method-not-found; complete fs/terminal non-advertisement matrix remains | +| Real adapter conformance | `NOT RUN` | No exact-version `codex-acp` or `claude-agent-acp` transcript is recorded | +| Rollback | `PARTIAL` | Existing direct `cosh-shell raw cosh-core` path remains; no installed ACP entrypoint smoke test | + +Source presence is not user-facing acceptance. Profile resolver tests that use +temporary executable files do not prove an installed official adapter works. + +## Acceptance matrix + +| ID | Criterion | Current result | Required proof | +| --- | --- | --- | --- | +| MVP-01 | One installed COSH entrypoint accepts a built-in profile, canonical workspace, and bounded text prompt | `NOT IMPLEMENTED` | Installed-binary integration test and `--help`/contract fixture | +| MVP-02 | Only locally installed `codex-acp` or `claude-agent-acp` is launched; native Codex/Claude, `npx`, shell, package runner, and network bootstrap are impossible | `PARTIAL` | Resolver source/tests plus entrypoint dependency and process-spawn review | +| MVP-03 | Profile resolution pins exact basename, canonical executable/workspace, fixed args, and allowlisted environment without logging values | `PARTIAL` | Positive and spoof/path/environment tests on the entrypoint path | +| MVP-04 | Driver performs ACP v1 initialize, one session/new, and one active text prompt in order | `PARTIAL` | End-to-end driver fixture with wrong-order and duplicate-prompt negatives | +| MVP-05 | Text updates are delivered in receive order with bounded local sequence, queue depth, and bytes | `NOT IMPLEMENTED` | Multi-chunk and saturation fixtures | +| MVP-06 | Every turn reports exactly one terminal result and rejects late updates | `PARTIAL` | Completion/cancel/error/exit/timeout race matrix | +| MVP-07 | Cancel reaches the driver while Agent stdout is silent and settles protocol/process state within configured bounds | `PARTIAL` | Independent-control fake-Agent test passes; completion/cancel race matrix remains | +| MVP-08 | Cancel settles every pending permission and no late decision or update can authorize work | `PARTIAL` | Permission-during-cancel and late-response race fixtures | +| MVP-09 | Permission proxy offers only correlated `allow_once` and `reject_once`; `allow_always` and `reject_always` cannot create a decision or rule | `NOT IMPLEMENTED` | Local decision-surface and unsupported-option tests | +| MVP-10 | Permission evidence is bounded, redacted, and records request correlation plus decision class | `NOT IMPLEMENTED` | Evidence schema, secret/log injection, and bounds tests | +| MVP-11 | fs, terminal, load, resume, rich content, additional directories, and multiple sessions remain unadvertised and fail closed | `PARTIAL` | Complete capability/request negative matrix with zero host I/O | +| MVP-12 | Malformed/oversized/invalid UTF-8/contaminated stdout, stderr flood, child exit, and timeout terminate safely with one reaped child | `PARTIAL` | Adversarial process fixtures and leak assertions | +| MVP-13 | At least one installed real adapter completes initialize, prompt, multiple streamed text updates, terminal result, active cancel, allow once, and reject once | `NOT RUN` | Sanitized exact-version transcript and command results at candidate SHA | +| MVP-14 | Disabling or not selecting ACP preserves the current direct cosh-core path | `PARTIAL` | Installed rollback smoke test | +| MVP-15 | English/Chinese MVP and aggregate documents remain semantically equivalent and all relative links resolve | `PASS for document slice` | Documentation checks recorded below | + +MVP-01 through MVP-15 are mandatory. MVP-13 may use either official adapter, +but the acceptance report must state which profile passed. The other profile +remains `NOT RUN` or records its own result. + +## Required automated evidence + +The implementation report must record exact commands and counts for equivalent +coverage: + +```text +profile resolver unit tests +ACP codec and supervised bridge tests +session driver protocol tests +installed local entrypoint integration tests +silent-Agent cancellation race tests +permission allow/reject/cancel tests +malformed-output and process-leak tests +rollback smoke test +``` + +The fake-Agent corpus must include: + +- normal initialization and at least two text chunks; +- wrong version, malformed JSON, invalid UTF-8, stdout log contamination, + oversized frame, stderr flood, and early exit; +- a silent prompt that is cancelled through the independent control handle; +- allow-once, reject-once, unsupported-only options, duplicate IDs, late + decisions, and cancellation while permission is pending; +- unadvertised filesystem, terminal, load, and resume requests with proof that + no host callback executed; +- output saturation and cancellation/completion races. + +## Required real-adapter evidence + +Acceptance requires one locally installed `codex-acp` or +`claude-agent-acp`. The evidence package records: + +1. full candidate commit SHA and operating-system environment; +2. selected profile and canonical adapter path without credentials; +3. adapter executable version and installation source; +4. exact COSH entrypoint commands for normal prompt and cancellation; +5. sanitized transcript proving initialization, at least two ordered text + updates, one terminal, allow once, reject once, and active cancellation; +6. confirmation that no `npx`, download, network bootstrap, filesystem callback, + or terminal callback was used by COSH; +7. any unsupported or untested behavior for the other built-in profile. + +Provider output, prompts, credentials, environment values, host identifiers, +and private workspace contents must be removed from evidence. + +## Exit criteria + +The ACP MVP is accepted only when: + +1. MVP-01 through MVP-15 are `PASS` on one exact candidate commit. +2. The installed entrypoint and fake-Agent failure/race suite pass with exact + counts. +3. At least one real official adapter passes the complete prompt, stream, + cancel, allow-once, and reject-once scenario. +4. The acceptance report names every timeout, frame, queue, stderr, and shutdown + bound used by the passing revision. +5. The report states explicitly that the result is not G1/G2, durable + governance, filesystem/terminal, Web, Shell attachment, or daemon acceptance. + +## Documentation validation for this slice + +The documentation-only change must pass repository docs lint, relative-link +checking, bilingual pairing/parity review, and `git diff --check`. It does not +run Cargo, a provider, ECS, or a real adapter and cannot change MVP-13 from +`NOT RUN`. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance_zh.md new file mode 100644 index 0000000000..cb8f7bdde4 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/acceptance_zh.md @@ -0,0 +1,124 @@ +# ACP v1 本地 Runtime MVP 验收报告 + +[English](acceptance.md) | [设计](design_zh.md) | +[规划集](../../README_zh.md) + +## 结果 + +**PARTIAL IMPLEMENTATION / NOT ACCEPTED。** 候选树已有严格 ACP v1 codec、 +supervised stdio Bridge、带独立 cancellation 的有界 Session Driver、确定性 fake-Agent +fixture,以及面向 `codex-acp` 和 `claude-agent-acp` 的内置 profile resolver。它仍没有 +已安装 COSH entrypoint、local permission UI/evidence record 或真实 Adapter 证据。 + +本 Gate 独立于完整 G1 与 G2 验收。即使通过,也只证明设计中定义的窄范围本地互操作结果。 + +## 状态词表 + +| 状态 | 含义 | +| --- | --- | +| `PASS` | 精确 candidate evidence 满足完整 MVP criterion | +| `PARTIAL` | 已有有界 source/test slice,但用户路径或必需证明仍不完整 | +| `FAIL` | 已实现行为经过测试后违反 criterion | +| `NOT IMPLEMENTED` | 必需 production surface 不存在 | +| `NOT RUN` | Surface 已存在,但必需证据未执行 | + +## 当前证据 + +| Area | 当前状态 | 证据与缺口 | +| --- | --- | --- | +| ACP v1 codec | `PARTIAL` | Exact wire v1 initialization、单 session、text prompt/update/stop、bound 与 malformed-input handling 有 focused fixture;未运行真实 Adapter | +| Supervised stdio | `PARTIAL` | Bridge 组合一个 Supervisor 与带 deadline/backpressure 的有界 Driver;更广的 race 与 process-tree fixture 仍缺 | +| Runtime profile | `PARTIAL` | 内置 resolver 固定 `codex-acp` 与 `claude-agent-acp`、canonical executable/workspace、fixed args 与 environment allowlist;无已安装用户 entrypoint | +| Streaming | `PARTIAL` | 有界 Driver 按接收顺序交付 decoded observation,saturation 时 fail closed;local sequence 与 presentation 未完成 | +| Cancellation | `PARTIAL` | Independent control 能触达 silent Agent、结算 pending permission callback 并 reap process;更广 race coverage 仍缺 | +| Permission correlation | `PARTIAL` | Offered request/option ID 已校验,durable option 被拒绝且 response single-use;无 local user decision surface 或 evidence record | +| Unsupported callback | `PARTIAL` | Fake fs request 收到有关联 method-not-found;完整 fs/terminal non-advertisement matrix 待补 | +| 真实 Adapter conformance | `NOT RUN` | 未记录 exact-version `codex-acp` 或 `claude-agent-acp` transcript | +| Rollback | `PARTIAL` | 现有 direct `cosh-shell raw cosh-core` path 保留;无已安装 ACP entrypoint smoke test | + +Source 存在不等于用户侧验收。使用临时 executable file 的 profile resolver test 不能证明 +已安装官方 Adapter 可工作。 + +## 验收矩阵 + +| ID | Criterion | 当前结果 | 必需证明 | +| --- | --- | --- | --- | +| MVP-01 | 一个已安装 COSH entrypoint 接受内置 profile、canonical workspace 与 bounded text prompt | `NOT IMPLEMENTED` | Installed-binary integration test 与 `--help`/contract fixture | +| MVP-02 | 只启动本地已安装 `codex-acp` 或 `claude-agent-acp`;不可能启动原生 Codex/Claude、`npx`、shell、package runner 或 network bootstrap | `PARTIAL` | Resolver source/test 加 entrypoint dependency 与 process-spawn review | +| MVP-03 | Profile resolve 固定 exact basename、canonical executable/workspace、fixed args 与 allowlisted environment,且不记录 value | `PARTIAL` | Entrypoint path 的 positive 与 spoof/path/environment test | +| MVP-04 | Driver 按序执行 ACP v1 initialize、单 session/new 与单 active text prompt | `PARTIAL` | End-to-end Driver fixture 以及 wrong-order/duplicate-prompt negative | +| MVP-05 | Text update 按接收顺序交付,带有界 local sequence、queue depth 与 byte | `NOT IMPLEMENTED` | Multi-chunk 与 saturation fixture | +| MVP-06 | 每轮只报告一个 terminal result,并拒绝 late update | `PARTIAL` | Completion/cancel/error/exit/timeout race matrix | +| MVP-07 | Agent stdout 静默时 cancel 仍到达 Driver,并在配置 bound 内 settle protocol/process state | `PARTIAL` | Independent-control fake-Agent test 通过;completion/cancel race matrix 仍缺 | +| MVP-08 | Cancel 结算所有 pending permission,late decision/update 不能授权工作 | `PARTIAL` | Permission-during-cancel 与 late-response race fixture | +| MVP-09 | Permission Proxy 只提供有关联的 `allow_once` 与 `reject_once`;`allow_always`/`reject_always` 不能生成 decision 或 rule | `NOT IMPLEMENTED` | Local decision surface 与 unsupported-option test | +| MVP-10 | Permission evidence 有界、脱敏,并记录 request correlation 与 decision class | `NOT IMPLEMENTED` | Evidence schema、secret/log injection 与 bounds test | +| MVP-11 | fs、terminal、load、resume、rich content、additional directory 与 multiple session 保持不声明并 fail closed | `PARTIAL` | 完整 capability/request negative matrix 与 zero host I/O | +| MVP-12 | Malformed/oversized/invalid UTF-8/contaminated stdout、stderr flood、child exit 与 timeout 安全终止并只 reap 一个 child | `PARTIAL` | Adversarial process fixture 与 leak assertion | +| MVP-13 | 至少一个已安装真实 Adapter 完成 initialize、prompt、多个 streamed text update、terminal、active cancel、allow once 与 reject once | `NOT RUN` | Candidate SHA 上的脱敏 exact-version transcript 与 command result | +| MVP-14 | 禁用或不选择 ACP 时保留当前 direct cosh-core path | `PARTIAL` | Installed rollback smoke test | +| MVP-15 | 中英文 MVP 与 aggregate 文档语义等价,全部 relative link 可解析 | `PASS for document slice` | 下述文档检查记录 | + +MVP-01 到 MVP-15 全部强制。MVP-13 可以使用任一官方 Adapter,但验收报告必须写明 +哪个 profile 通过;另一个 profile 保持 `NOT RUN` 或记录自己的结果。 + +## 必需自动化证据 + +实现报告必须记录下列等价 coverage 的 exact command 与 count: + +```text +profile resolver unit tests +ACP codec and supervised bridge tests +session driver protocol tests +installed local entrypoint integration tests +silent-Agent cancellation race tests +permission allow/reject/cancel tests +malformed-output and process-leak tests +rollback smoke test +``` + +Fake-Agent corpus 必须包含: + +- 正常 initialization 与至少两个 text chunk; +- wrong version、malformed JSON、invalid UTF-8、stdout log contamination、oversized + frame、stderr flood 与 early exit; +- 通过 independent control handle 取消的 silent prompt; +- allow-once、reject-once、unsupported-only option、duplicate ID、late decision, + 以及 permission pending 时 cancellation; +- 未声明 filesystem、terminal、load 与 resume request,并证明没有执行 host callback; +- output saturation 与 cancellation/completion race。 + +## 必需真实 Adapter 证据 + +验收要求一个本地已安装的 `codex-acp` 或 `claude-agent-acp`。Evidence package 记录: + +1. 完整 candidate commit SHA 与 operating-system environment; +2. Selected profile 与 canonical Adapter path,但不含 credential; +3. Adapter executable version 与 installation source; +4. Normal prompt 与 cancellation 的 exact COSH entrypoint command; +5. 脱敏 transcript,证明 initialization、至少两个有序 text update、唯一 terminal、 + allow once、reject once 与 active cancellation; +6. 确认 COSH 未使用 `npx`、download、network bootstrap、filesystem callback 或 + terminal callback; +7. 另一个内置 profile 的 unsupported 或 untested behavior。 + +Evidence 必须移除 provider output、prompt、credential、environment value、host identifier +与 private workspace content。 + +## Exit Criteria + +ACP MVP 只在以下条件全部成立时接受: + +1. MVP-01 到 MVP-15 在同一个 exact candidate commit 上全部为 `PASS`。 +2. Installed entrypoint 与 fake-Agent failure/race suite 通过并记录 exact count。 +3. 至少一个真实官方 Adapter 通过完整 prompt、stream、cancel、allow-once 与 + reject-once scenario。 +4. 验收报告写明 passing revision 使用的全部 timeout、frame、queue、stderr 与 shutdown bound。 +5. 报告明确说明该结果不是 G1/G2、durable governance、filesystem/terminal、Web、 + Shell Attachment 或 daemon acceptance。 + +## 本切片文档验证 + +本次 documentation-only change 必须通过仓库 docs lint、relative-link check、双语 pairing/parity +review 与 `git diff --check`。它不运行 Cargo、provider、ECS 或真实 Adapter,因此不能把 +MVP-13 从 `NOT RUN` 改为通过。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design.md new file mode 100644 index 0000000000..a623ff68fa --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design.md @@ -0,0 +1,246 @@ +# ACP v1 Local Runtime MVP Design + +[中文版](design_zh.md) | [Acceptance report](acceptance.md) | +[Planning set](../../README.md) + +## Status and delivery decision + +This module defines a narrow delivery gate for launching one locally installed +ACP adapter and completing one text turn. It is smaller than the complete Phase +2 ACP, Shell attachment, Web, durable Gateway, and OS-governance gates. + +The candidate worktree already provides: + +- an official ACP Rust SDK 2.0.0 codec for stable ACP wire version 1; +- a synchronous `AcpV1RuntimeBridge` composed with one + `RuntimeSupervisor` that owns the child process, stdio, process group, and + reap result; +- bounded initialization, one session, one active text prompt, streamed + `session/update`, permission correlation, cancellation frames, and + fail-closed decoding; +- built-in `Codex` and `ClaudeCode` profile resolution for the locally + installed `codex-acp` and `claude-agent-acp` adapters. + +These are library-level foundations. No installed COSH entrypoint selects a +profile and drives a complete turn. The current synchronous bridge does not +provide an independent cancellation control path while its owner is blocked +reading Agent stdout. Permission responses are correlated ACP wire responses, +not yet a COSH approval or Capability Broker decision. No real adapter evidence +has been recorded. + +## MVP outcome + +An installed COSH command can select one built-in profile, pin one canonical +workspace, launch the corresponding local adapter over stdio, send one text +prompt, stream ordered text updates, resolve or reject a once-only permission +request, cancel independently, and report one terminal result. + +At least one of the two real adapters must pass the complete acceptance matrix +before this MVP is accepted. Supporting both profiles in source does not imply +that both adapters passed live conformance. + +## Scope + +The complete MVP profile is deliberately fixed: + +| Dimension | MVP contract | +| --- | --- | +| Transport | Local subprocess stdio with newline-delimited ACP v1 JSON-RPC | +| Adapter profiles | Installed `codex-acp` and `claude-agent-acp` only | +| Workspace | One canonical absolute directory fixed before launch | +| Connection | One supervised adapter process and ACP connection | +| Session | One opaque ACP session per driver | +| Concurrency | One active prompt and one ordered event stream | +| Prompt content | Non-empty bounded UTF-8 text only | +| Permission | Offered once-only allow or reject decisions | +| Cancellation | Independent control command with bounded escalation and reap | +| Presentation | Bounded text/events and safe diagnostics at the local entrypoint | + +## Explicit non-goals + +The MVP does not include: + +- native ACP support in the Codex or Claude Code binaries; +- downloading or executing adapters through `npx`, a shell, a package runner, + or any network bootstrap; +- filesystem callbacks, terminal callbacks, rich prompt content, additional + directories, session load, session resume, or multi-session operation; +- `allow_always`, `reject_always`, durable trust rules, or policy mutation; +- Web, channel, Shell attachment, Gateway daemon, remote transport, or + cross-device replay; +- durable Task recovery, Run leases, process-transparent restart, or complete + Capability Broker governance. + +Unsupported features stay unadvertised. An Agent request for an unadvertised +filesystem or terminal method receives a correlated method-not-found response +and never reaches host I/O. + +## Runtime profile boundary + +The built-in profile resolver is the only MVP adapter selection authority: + +| Profile ID | Required executable | Launch rule | +| --- | --- | --- | +| `Codex` | `codex-acp` | Resolve an installed regular executable with the exact basename | +| `ClaudeCode` | `claude-agent-acp` | Resolve an installed regular executable with the exact basename | + +An explicit executable must be absolute. Implicit resolution searches only +absolute `PATH` entries. The resolver canonicalizes the executable and +workspace, uses fixed empty argument lists, clears inherited environment, and +copies only the common and profile-specific allowlisted variables. Prompts, +ACP payloads, and adapter output cannot add process arguments, replace the +executable, or change the workspace. + +The adapter executables are separate installed adapters. Documentation and UI +must not claim that the native `codex` or `claude` command implements ACP. + +## Local entrypoint + +The MVP requires one installed COSH-owned entrypoint with the conceptual input: + +```text +RunAcpPrompt { + profile, + workspace, + prompt +} +``` + +The final executable and flag spelling is an implementation decision, but the +entrypoint must: + +1. accept only a built-in profile ID; +2. resolve the profile before spawning; +3. keep adapter installation external and return a typed missing-adapter error; +4. expose streamed updates, permission requests, cancellation, and one terminal + result without exposing SDK objects as a public COSH contract; +5. leave `cosh-shell raw cosh-core` unchanged when the ACP entrypoint or profile + is not selected. + +The entrypoint is local process orchestration, not the Phase 1 authenticated +Gateway API or a daemon. + +## Session driver ownership + +The MVP adds one driver above the current bridge: + +```text +local entrypoint + -> profile resolver + -> ACP session driver + -> AcpV1RuntimeBridge + owns AcpV1Codec + RuntimeSupervisor + owns child + stdio + process group + reap +``` + +This composition is the current ownership model. The bridge owns its embedded +supervisor; it does not borrow a channel from a separate daemon supervisor. +There is exactly one process owner and one codec owner. + +The session driver owns command serialization, event sequencing, deadlines, +and the independent cancellation handle. It performs: + +```text +resolve profile + -> launch adapter + -> initialize(protocolVersion = 1) + -> session/new(canonical workspace) + -> session/prompt(text) + -> zero or more ordered updates/permission requests + -> prompt terminal, cancellation settlement, or transport failure + -> shutdown and reap +``` + +Only the driver task/thread mutates the bridge. A separate control handle sends +cancel into the driver command queue, so cancellation remains available while +the driver is waiting for Agent stdout. A design that requires acquiring the +same blocked `&mut` bridge directly does not meet the MVP. + +## Streaming and terminal semantics + +- Each accepted `session/update` receives one monotonically increasing local + sequence before delivery. +- The MVP presents only text agent-message chunks. Other valid updates are + bounded diagnostic events or explicit unsupported events; they are not + silently converted to text success. +- Queue depth and byte limits are explicit. Saturation cancels and fails the + turn instead of buffering without a bound. +- Exactly one terminal result is delivered for the prompt: completed, + cancelled, Agent error, protocol failure, process exit, or timeout. +- Updates received after terminal settlement are rejected and cannot change the + reported result. +- Raw prompts, environment values, unrestricted stderr, and adapter payloads + are absent from retained evidence. + +## Independent cancellation + +Cancellation is accepted from the local entrypoint even when no Agent update is +arriving. The driver: + +1. records a local cancellation request; +2. sends ACP `session/cancel` and cancelled outcomes for every pending + permission callback; +3. waits a bounded protocol grace for the prompt to settle; +4. closes or stops protocol input when retiring the connection; +5. escalates to process-group termination and kill through the embedded + `RuntimeSupervisor`; +6. reaps the child and reader state; +7. emits one cancelled or explicit cleanup-failure terminal result. + +Cancellation races with prompt completion use first-terminal-wins semantics. +No permission response or Agent update may authorize work after cancellation +has won. + +## Permission proxy + +The MVP permission boundary is a local, once-only permission proxy. It is not a +claim of durable Task approval or complete Capability Broker governance. + +For each `session/request_permission`: + +1. validate the active session, prompt, JSON-RPC request ID, tool call, and + unique option IDs; +2. retain only offered `allow_once` and `reject_once` choices; +3. present untrusted labels as display data; +4. accept one local user decision correlated to that request; +5. return the selected offered option or a cancelled/rejected outcome; +6. reject duplicates, unknown options, late decisions, and cross-session IDs. + +`allow_always` and `reject_always` are not offered by COSH in the MVP and cannot +create a durable rule. If the Agent supplies only unsupported choices, the +request fails closed. The evidence record contains bounded correlation and the +decision class, not raw tool input or credentials. + +## Failure boundaries + +| Failure | Required result | +| --- | --- | +| Adapter missing or wrong basename | Fail before spawn with a typed profile error | +| Workspace missing or not a directory | Fail before spawn | +| Wrong ACP version or initialization timeout | Terminate and reap; compatibility failure | +| Malformed, oversized, invalid UTF-8, or contaminated stdout | Fail closed and terminate the process group | +| Stderr flood | Retain only a bounded safe tail; never parse it as ACP | +| Agent exits during prompt | One transport/process terminal; never infer success | +| Permission has no supported once option | Reject or cancel without authorization | +| Cancel arrives while stdout is silent | Driver receives it independently and settles within bounds | +| Output queue saturates | Cancel/fail with a stable overload result | +| Unsupported callback | Correlated method-not-found; no host side effect | + +## Delivery sequence + +1. Freeze this MVP contract and the entrypoint/event/error vocabulary. +2. Keep the existing resolver and bridge composition; document exact ownership. +3. Add the session driver and independent control channel. +4. Add the installed local entrypoint and safe presentation. +5. Add the once-only permission proxy and evidence record. +6. Complete deterministic fake-Agent failure/race coverage. +7. Run exact-revision conformance against at least one installed official + adapter and record sanitized evidence. + +## Relationship to later gates + +Passing this MVP proves local ACP prompt, stream, cancel, and once-only +permission interoperability. It does not pass G1 or G2. Durable Task mapping, +Capability Broker authorization, filesystem/terminal callbacks, restart, +Shell/Web attachment, and remote presentation retain their existing module +acceptance criteria. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design_zh.md new file mode 100644 index 0000000000..32da22fc0a --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/acp-mvp/design_zh.md @@ -0,0 +1,216 @@ +# ACP v1 本地 Runtime MVP 设计 + +[English](design.md) | [验收报告](acceptance_zh.md) | +[规划集](../../README_zh.md) + +## 状态与交付决策 + +本模块定义一个范围明确的交付 Gate,用于启动一个本地已安装 ACP Adapter,并完成 +一轮 text turn。它小于完整 Phase 2 ACP、Shell Attachment、Web、持久 Gateway +与 OS 治理 Gate。 + +候选工作树已经具备: + +- 基于官方 ACP Rust SDK 2.0.0、面向稳定 ACP wire version 1 的 codec; +- 同一个 `AcpV1RuntimeBridge` 中组合一个 `RuntimeSupervisor`,由后者拥有 + child process、stdio、process group 与 reap result; +- 有界 initialization、单 session、单 active text prompt、流式 + `session/update`、permission correlation、cancellation frame 与 fail-closed + decode; +- 面向本地已安装 `codex-acp` 与 `claude-agent-acp` Adapter 的内置 `Codex` + 和 `ClaudeCode` profile resolver。 + +这些仍是 library-level foundation。当前没有已安装的 COSH entrypoint 选择 profile +并驱动完整 turn。同步 Bridge 的 owner 阻塞等待 Agent stdout 时,尚无独立 cancellation +control path。Permission response 是有关联的 ACP wire response,尚不是 COSH approval +或 Capability Broker decision。也没有记录真实 Adapter 证据。 + +## MVP 结果 + +一个已安装的 COSH command 可以选择内置 profile,固定一个 canonical workspace, +通过 stdio 启动对应的本地 Adapter,发送一个 text prompt,流式输出有序 text update, +处理或拒绝一次性 permission request,独立 cancel,并报告唯一 terminal result。 + +本 MVP 被接受前,两个真实 Adapter 中至少一个必须通过完整验收矩阵。源码支持两个 +profile 不代表两个 Adapter 都通过了 live conformance。 + +## 范围 + +MVP profile 固定如下: + +| 维度 | MVP 契约 | +| --- | --- | +| Transport | 本地 subprocess stdio,使用 newline-delimited ACP v1 JSON-RPC | +| Adapter profile | 仅已安装的 `codex-acp` 与 `claude-agent-acp` | +| Workspace | Launch 前固定的单个 canonical absolute directory | +| Connection | 单一 supervised Adapter process 与 ACP connection | +| Session | 每个 Driver 一个 opaque ACP session | +| Concurrency | 一个 active prompt 与一条有序 event stream | +| Prompt content | 非空、有界 UTF-8 text only | +| Permission | Agent 提供的一次性 allow 或 reject decision | +| Cancellation | 独立 control command、有界 escalation 与 reap | +| Presentation | Local entrypoint 输出有界 text/event 与安全 diagnostics | + +## 明确非目标 + +MVP 不包括: + +- Codex 或 Claude Code binary 原生实现 ACP; +- 通过 `npx`、shell、package runner 或任何 network bootstrap 下载或执行 Adapter; +- filesystem callback、terminal callback、rich prompt content、additional directory、 + session load、session resume 或 multi-session; +- `allow_always`、`reject_always`、持久 trust rule 或 policy mutation; +- Web、渠道、Shell Attachment、Gateway daemon、远端 transport 或跨设备 replay; +- 持久 Task recovery、Run lease、进程无感 restart 或完整 Capability Broker 治理。 + +不支持的 feature 保持不声明。Agent 请求未声明的 filesystem 或 terminal method 时, +返回有关联的 method-not-found response,绝不进入 host I/O。 + +## Runtime Profile 边界 + +内置 profile resolver 是 MVP 唯一的 Adapter 选择 authority: + +| Profile ID | 必需 executable | Launch 规则 | +| --- | --- | --- | +| `Codex` | `codex-acp` | 解析 basename 完全一致的已安装 regular executable | +| `ClaudeCode` | `claude-agent-acp` | 解析 basename 完全一致的已安装 regular executable | + +显式 executable 必须是 absolute path。隐式解析只搜索 `PATH` 中的 absolute entry。 +Resolver canonicalize executable 与 workspace,使用固定的空 argument list,清空继承环境, +只复制 common 与 profile-specific allowlisted variable。Prompt、ACP payload 与 Adapter +output 均不能增加 process argument、替换 executable 或改变 workspace。 + +这些 executable 是单独安装的 Adapter。文档与 UI 不得声称原生 `codex` 或 `claude` +command 已实现 ACP。 + +## Local Entrypoint + +MVP 要求一个已安装、由 COSH 拥有的 entrypoint,概念输入为: + +```text +RunAcpPrompt { + profile, + workspace, + prompt +} +``` + +最终 executable 与 flag 名称由实现决定,但 entrypoint 必须: + +1. 只接受内置 profile ID; +2. Spawn 前完成 profile resolve; +3. 由外部负责 Adapter 安装,缺失时返回 typed missing-adapter error; +4. 对外提供 streamed update、permission request、cancellation 与唯一 terminal result, + 且不把 SDK object 暴露为 public COSH contract; +5. 未选择 ACP entrypoint 或 profile 时,不改变 `cosh-shell raw cosh-core`。 + +该 entrypoint 是本地 process orchestration,不是 Phase 1 authenticated Gateway API 或 daemon。 + +## Session Driver Ownership + +MVP 在当前 Bridge 之上增加一个 Driver: + +```text +local entrypoint + -> profile resolver + -> ACP session driver + -> AcpV1RuntimeBridge + owns AcpV1Codec + RuntimeSupervisor + owns child + stdio + process group + reap +``` + +这是当前 composition 对应的 ownership model。Bridge 拥有其内嵌 Supervisor,不从独立 +daemon supervisor 借用 channel。系统中只有一个 process owner 和一个 codec owner。 + +Session Driver 拥有 command serialization、event sequencing、deadline 与独立 cancellation +handle。它执行: + +```text +resolve profile + -> launch adapter + -> initialize(protocolVersion = 1) + -> session/new(canonical workspace) + -> session/prompt(text) + -> zero or more ordered updates/permission requests + -> prompt terminal, cancellation settlement, or transport failure + -> shutdown and reap +``` + +只有 Driver task/thread 可以修改 Bridge。独立 control handle 把 cancel 发送到 Driver command +queue,因此 Driver 等待 Agent stdout 时仍可以 cancel。必须直接获取同一个被阻塞 `&mut` +Bridge 的设计不满足 MVP。 + +## Streaming 与 Terminal 语义 + +- 每个接受的 `session/update` 在 delivery 前获得单调递增 local sequence。 +- MVP 只展示 text agent-message chunk。其他有效 update 变为有界 diagnostic event 或显式 + unsupported event,不能静默转换为 text success。 +- Queue depth 与 byte limit 必须明确。Saturation 应 cancel 并令 turn 失败,不能无界 buffer。 +- 每个 prompt 只交付一个 terminal result,包括 completed、cancelled、Agent error、protocol + failure、process exit 或 timeout。 +- Terminal settlement 后收到的 update 必须拒绝,不能改变已报告结果。 +- 保留证据中不含 raw prompt、environment value、无限制 stderr 或 Adapter payload。 + +## 独立 Cancellation + +即使 Agent 没有输出,local entrypoint 也能接受 cancellation。Driver: + +1. 记录 local cancellation request; +2. 发送 ACP `session/cancel`,并为每个 pending permission callback 发送 cancelled outcome; +3. 在有界 protocol grace 内等待 prompt settle; +4. Connection 退出时关闭或停止 protocol input; +5. 通过内嵌 `RuntimeSupervisor` escalation 到 process-group termination 与 kill; +6. Reap child 与 reader state; +7. 发出唯一 cancelled 或显式 cleanup-failure terminal result。 + +Cancellation 与 prompt completion race 采用 first-terminal-wins。Cancel 已经获胜后,任何 +permission response 或 Agent update 都不能授权工作。 + +## Permission Proxy + +MVP permission 边界是本地 once-only permission proxy,不声称具备持久 Task approval 或 +完整 Capability Broker 治理。 + +每个 `session/request_permission` 按以下规则处理: + +1. 校验 active session、prompt、JSON-RPC request ID、tool call 与唯一 option ID; +2. 只保留 Agent 提供的 `allow_once` 与 `reject_once` choice; +3. 把不可信 label 仅作为 display data; +4. 接受一个与 request 关联的 local user decision; +5. 返回 Agent 已提供的 selected option,或 cancelled/rejected outcome; +6. 拒绝 duplicate、unknown option、late decision 与 cross-session ID。 + +MVP 不向用户提供 `allow_always` 或 `reject_always`,也不能创建 durable rule。Agent 只提供 +unsupported choice 时必须 fail closed。Evidence record 只包含有界 correlation 与 decision +class,不含 raw tool input 或 credential。 + +## Failure 边界 + +| Failure | 必需结果 | +| --- | --- | +| Adapter 缺失或 basename 错误 | Spawn 前以 typed profile error 失败 | +| Workspace 缺失或不是 directory | Spawn 前失败 | +| ACP version 错误或 initialization timeout | Terminate 并 reap;报告 compatibility failure | +| Malformed、oversized、invalid UTF-8 或 contaminated stdout | Fail closed 并终止 process group | +| Stderr flood | 只保留 bounded safe tail;绝不作为 ACP 解析 | +| Agent 在 prompt 中退出 | 唯一 transport/process terminal;不得推断 success | +| Permission 没有受支持的 once option | 不授权并 reject 或 cancel | +| Stdout 静默时收到 cancel | Driver 独立接收并在有界时间内 settle | +| Output queue saturation | Cancel/fail 并返回稳定 overload result | +| Unsupported callback | 有关联的 method-not-found;无 host side effect | + +## 交付顺序 + +1. 冻结本 MVP contract 以及 entrypoint/event/error vocabulary。 +2. 保留现有 resolver 与 Bridge composition,并记录准确 ownership。 +3. 增加 Session Driver 与独立 control channel。 +4. 增加已安装 local entrypoint 与安全 presentation。 +5. 增加 once-only permission proxy 与 evidence record。 +6. 完成确定性 fake-Agent failure/race coverage。 +7. 针对至少一个已安装官方 Adapter,在精确 revision 上运行 conformance 并记录脱敏证据。 + +## 与后续 Gate 的关系 + +通过本 MVP 只证明本地 ACP prompt、stream、cancel 与 once-only permission 互操作,不通过 +G1 或 G2。持久 Task mapping、Capability Broker authorization、filesystem/terminal callback、 +restart、Shell/Web Attachment 与远端 presentation 继续使用原模块验收标准。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance.md new file mode 100644 index 0000000000..c7fb927735 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance.md @@ -0,0 +1,131 @@ +# Phase 1 Capability Broker Acceptance Report + +[中文版](acceptance_zh.md) | [Design](design.md) + +## Result + +**Overall: PARTIAL. The process-local broker logic slice passes; Phase 1 does not.** The +implementation worktree is based on +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. + +The Gateway now validates Capability request expiry, authoritative Task, Run, complete Actor +provenance, target, operation descriptor, complete operation digest, and requested scope before +policy. It separates policy decisions from permits, issues exactly bound single-use permits, and +atomically consumes them in a process-local memory store. Eight targeted tests pass. + +This result is not an end-to-end governance claim. `MemoryPermitStore` is non-durable. Approval +resolution and re-authorization are absent. There is no immutable target resolver, lease/runtime +fence, durable permit/execution ledger, audit gate, OS executor, revocation, crash recovery, +reconciliation, network API, or ACP integration. Existing CLI/Core/Shell mutation paths still +bypass this slice. + +## Result vocabulary + +| Result | Meaning | +| --- | --- | +| PASS | Reproducible evidence satisfies the complete criterion for the stated scope. | +| PARTIAL | Implemented evidence satisfies only the explicitly listed subset. | +| FAIL | An enabled current path violates the target invariant. | +| NOT IMPLEMENTED | No implementation exists for the criterion. | +| BLOCKED | A prerequisite decision prevents verification. | + +## Implementation evidence + +| Source | Verified behavior | +| --- | --- | +| [`capability.rs`](../../../../../crates/cosh-gateway/src/capability.rs) | Exposes the Broker, policy, permit-store, claim, context, and memory-store boundaries without exposing an executor | +| [`broker.rs`](../../../../../crates/cosh-gateway/src/capability/broker.rs) | Validates expiry, Task/Run/full `ActorRef`, and authoritative target/descriptor/full-operation digest/scope before policy; rejects unavailable or invalid policy authority and exposes atomic claim | +| [`memory.rs`](../../../../../crates/cosh-gateway/src/capability/memory.rs) | Holds permit validation and consumption under one mutex; mismatch, expiry, and replay fail closed | +| [`memory/tests.rs`](../../../../../crates/cosh-gateway/src/capability/memory/tests.rs) | Covers parent and actor-provenance substitution, policy branches/failures, permit binding, mismatch, expiry/replay, and concurrent consumption | +| [`capability.rs`](../../../../../crates/cosh-gateway-contracts/src/capability.rs) | Defines neutral request, decision, approval, and permit contracts with Actor/Task/Run/Execution/target/operation/policy/expiry bindings | + +The Broker source depends on contracts and its two explicit ports. It does not import Task storage, +Runtime bridges, OS operators, ACP, or network APIs. + +## Acceptance matrix + +| ID | Criterion | Result | Evidence or remaining gap | +| --- | --- | --- | --- | +| CBR-001 | Every side-effect request uses typed `CapabilityRequest`. | FAIL | Broker input is typed, but existing CLI/Core/Shell mutation paths bypass it. | +| CBR-002 | Target resolves to an immutable authenticated identity. | NOT IMPLEMENTED | The first slice binds exact `TargetRef`; there is no resolver, boot/workspace/UID identity, or attestation. | +| CBR-003 | Policy result, approval, and permit are distinct types. | PARTIAL | `PolicyDecision`, `ApprovalRequest`, `CapabilityDecision`, and `ExecutionPermit` are distinct; durable approval resolution/re-authorization is absent. | +| CBR-004 | Every permitted effect has one `ExecutionId`. | PARTIAL | Every Broker-issued permit gets one `ExecutionId`; bypass paths and execution lifecycle are not integrated. | +| CBR-005 | Permit binds actor, Task, Run, target, operation digest, policy, fence, expiry, and one use. | PARTIAL | Actor/Task/Run/Execution/exact target/complete operation digest/policy/expiry/single-use bindings pass; immutable target and runtime/lease fence remain. Canonicalization and hashing still rely on trusted ingress. | +| CBR-006 | Target verifies and consumes permit immediately before execution. | PARTIAL | `claim` atomically verifies and consumes, but no target adapter or durable store invokes it before an effect. | +| CBR-007 | Approval is durable Task state and cannot widen authority. | PARTIAL | Approval requests preserve request/Task/Run/expiry; there is no durable approval ledger, resolution, or approval-bound issuance. Direct permits have `approval_id = None`. | +| CBR-008 | Broker never writes the Task aggregate. | PASS | Broker has no Task aggregate or storage dependency and returns decisions only. | +| CBR-009 | Repeated execute cannot produce a second effect. | PARTIAL | One of eight concurrent claims wins and replay fails in one process; there is no durable execution/effect ledger across restart. | +| CBR-010 | Crash uncertainty triggers typed reconciliation, never automatic retry. | NOT IMPLEMENTED | No execution lifecycle or reconciliation port exists. | +| CBR-011 | Shell parsing fails closed on adversarial separators and metacharacters. | PASS | Existing parser/heuristic baseline remains reusable; the new Broker does not add a Shell fallback. | +| CBR-012 | Typed policy has allow/deny/require-approval outcomes. | PASS | Neutral `PolicyPort` and deterministic tests cover all three outcomes plus unavailable/invalid authority. | +| CBR-013 | Permit issuance and execution start require durable security audit. | NOT IMPLEMENTED | The memory store has no audit port or durable issuance gate. | +| CBR-014 | cosh-core direct side-effecting tools are disabled/delegated in brokered mode. | FAIL | Brokered Core integration does not exist. | +| CBR-015 | CLI/platform operations cannot bypass permit in governed mode. | FAIL | No governed operator integration exists. | +| CBR-016 | Canonical remote target identity/attestation is approved. | BLOCKED | The target identity decision and implementation remain open. | + +## Validation evidence + +Commands run from `src/cosh-ng`: + +```text +cargo fmt --package cosh-gateway -- --check +cargo test --locked --package cosh-gateway-contracts +result: 6 integration tests passed; unit and doc-test targets passed + +cargo test --locked --package cosh-gateway capability:: +result: 8 passed; 0 failed; 38 filtered out + +cargo clippy --locked --package cosh-gateway-contracts --all-targets -- -D warnings +cargo clippy --locked --package cosh-gateway --all-targets -- -D warnings +result: passed with zero warnings + +cargo doc --locked --package cosh-gateway-contracts --package cosh-gateway --no-deps +result: passed +``` + +The eight tests prove: + +- request expiry and Task, Run, Actor ID, issuer, assurance, target, operation descriptor, + complete operation digest, and scope substitution fail closed before policy; +- policy deny and approval never create a permit; +- policy unavailability, zero revision, and expired authority fail closed; +- an issued permit binds actor, Task, Run, Execution, target, complete canonical operation digest, policy revision, + expiry, and one use; +- wrong actor, Task, Run, Execution, target, complete operation digest, or policy revision does not consume + authority; +- expired and repeated consumption fail closed; +- exactly one of eight simultaneous claims succeeds. + +No ECS, provider, OS mutation, network, or ACP validation was run because this slice deliberately +contains no such adapter. + +## Required remaining artifacts + +| Artifact | Required proof | +| --- | --- | +| Durable approval and re-authorization tests | Only a committed, matching approval can issue a no-wider permit. | +| Immutable target-substitution matrix | Workspace, UID, boot, container, and instance changes invalidate permits. | +| Durable permit/execution ledger | Restart cannot restore consumed authority or duplicate a known/unknown effect. | +| Security audit gate | Issuance and execution start fail when required audit persistence fails. | +| Execution kill-point and reconciliation matrix | Claimed, started, and uncertain effects never auto-replay. | +| Broker bypass inventory | Every enabled Gateway/Core/Shell/ACP/Skill/MCP effect reaches the verifier. | +| Revocation and lease-fence corpus | Revoked, stale-runtime, and stale-policy authority fails closed. | +| Trusted canonicalizer tests | Independent canonicalization binds descriptor and digest before Broker admission. | + +## Exit criteria + +1. CBR-001 through CBR-015 are PASS; remote execution remains disabled until CBR-016 passes. +2. Approval resolution, permit issuance, durable consumption, audit, execution, and reconciliation + are one reviewed security boundary. +3. Immutable target identity and runtime/lease fencing replace the initial `TargetRef` binding. +4. The bypass inventory covers every enabled mutation edge with no direct executor path. +5. Crash, replay, substitution, audit-failure, and revocation fixtures pass on one exact commit. + +## Remaining risks + +- Process restart erases `MemoryPermitStore`, so it cannot protect production authority. +- An unresolved approval cannot authorize a permit; adding that path without durable matching + would create an authority-widening vulnerability. +- Exact `TargetRef` equality does not detect boot, UID, namespace, symlink, or workspace changes. +- A caller can still bypass the Broker through current Core, CLI, and platform execution paths. +- Claiming a permit without a durable execution state cannot reconcile a crash after an effect. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance_zh.md new file mode 100644 index 0000000000..05b894bd26 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/acceptance_zh.md @@ -0,0 +1,126 @@ +# Phase 1 Capability Broker 验收报告 + +[English](acceptance.md) | [设计](design_zh.md) + +## 结果 + +**整体结果为 PARTIAL。Process-local Broker 逻辑切片通过,Phase 1 尚未通过。** +实现 worktree 基于 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。 + +Gateway 现在会在 policy 前校验 Capability request expiry,以及 authoritative Task、Run、完整 Actor +provenance、target、operation descriptor、完整 operation digest 与 requested scope。它将 policy +decision 与 permit 分开,为 permit 绑定准确 authority,并通过 process-local memory store atomically +consume single-use permit。八个 targeted test 通过。 + +该结果不构成端到端治理声明。`MemoryPermitStore` 不持久。Approval resolution 与 re-authorization +不存在。Immutable target resolver、lease/runtime fence、durable permit/execution ledger、audit gate、 +OS executor、revocation、crash recovery、reconciliation、network API 与 ACP integration 均未实现。 +现有 CLI/Core/Shell mutation path 仍会绕过该切片。 + +## 结果口径 + +| 结果 | 含义 | +| --- | --- | +| PASS | 可复现证据满足所述 scope 的完整验收项。 | +| PARTIAL | 实现证据只满足明确列出的子集。 | +| FAIL | 当前启用 path 违反目标 invariant。 | +| NOT IMPLEMENTED | 该验收项没有实现。 | +| BLOCKED | 前置决策阻止验证。 | + +## 实现证据 + +| 来源 | 已验证行为 | +| --- | --- | +| [`capability.rs`](../../../../../crates/cosh-gateway/src/capability.rs) | 公开 Broker、policy、permit-store、claim、context 与 memory-store 边界,不公开 executor | +| [`broker.rs`](../../../../../crates/cosh-gateway/src/capability/broker.rs) | 在 policy 前校验 expiry、Task/Run/完整 `ActorRef` 与 authoritative target/descriptor/完整 operation digest/scope;拒绝 unavailable 或 invalid policy authority,并公开 atomic claim | +| [`memory.rs`](../../../../../crates/cosh-gateway/src/capability/memory.rs) | 在同一个 mutex 内校验并 consume permit;mismatch、expiry 与 replay fail closed | +| [`memory/tests.rs`](../../../../../crates/cosh-gateway/src/capability/memory/tests.rs) | 覆盖 parent 与 actor-provenance substitution、policy branch/failure、permit binding、mismatch、expiry/replay 与 concurrent consumption | +| [`capability.rs`](../../../../../crates/cosh-gateway-contracts/src/capability.rs) | 定义中立 request、decision、approval 与 permit contract,包含 Actor/Task/Run/Execution/target/operation/policy/expiry binding | + +Broker source 只依赖 contracts 和两个显式 port,不 import Task storage、Runtime bridge、OS operator、 +ACP 或 network API。 + +## 验收矩阵 + +| ID | 验收项 | 结果 | 证据或剩余缺口 | +| --- | --- | --- | --- | +| CBR-001 | 所有 side-effect request 使用 typed `CapabilityRequest`。 | FAIL | Broker input 已类型化,但现有 CLI/Core/Shell mutation path 绕过它。 | +| CBR-002 | Target 解析成 immutable authenticated identity。 | NOT IMPLEMENTED | 第一版只绑定 exact `TargetRef`;没有 resolver、boot/workspace/UID identity 或 attestation。 | +| CBR-003 | Policy result、approval 与 permit 是不同类型。 | PARTIAL | `PolicyDecision`、`ApprovalRequest`、`CapabilityDecision` 与 `ExecutionPermit` 已分离;durable approval resolution/re-authorization 不存在。 | +| CBR-004 | 每个 permitted effect 有一个 `ExecutionId`。 | PARTIAL | 每个 Broker-issued permit 都有一个 `ExecutionId`;bypass path 与 execution lifecycle 未集成。 | +| CBR-005 | Permit 绑定 actor、Task、Run、target、operation digest、policy、fence、expiry 与一次使用。 | PARTIAL | Actor/Task/Run/Execution/exact target/完整 operation digest/policy/expiry/single-use binding 通过;immutable target 与 runtime/lease fence 尚缺。Canonicalization 与 hashing 仍依赖 trusted ingress。 | +| CBR-006 | Target 在执行前立即校验并 consume permit。 | PARTIAL | `claim` atomically 校验并 consume,但没有 target adapter 或 durable store 在 effect 前调用。 | +| CBR-007 | Approval 是 durable Task state 且不能扩大 authority。 | PARTIAL | Approval request 保留 request/Task/Run/expiry;没有 durable approval ledger、resolution 或 approval-bound issuance。Direct permit 的 `approval_id = None`。 | +| CBR-008 | Broker 不写 Task aggregate。 | PASS | Broker 不依赖 Task aggregate 或 storage,只返回 decision。 | +| CBR-009 | 重复 execute 不能产生第二个 effect。 | PARTIAL | 八个 concurrent claim 只有一个成功,单进程 replay 失败;没有跨 restart 的 durable execution/effect ledger。 | +| CBR-010 | Crash uncertainty 进入 typed reconciliation,不自动 retry。 | NOT IMPLEMENTED | 没有 execution lifecycle 或 reconciliation port。 | +| CBR-011 | Shell parsing 对 adversarial separator 与 metacharacter fail closed。 | PASS | 当前 parser/heuristic baseline 仍可复用;新 Broker 没有增加 Shell fallback。 | +| CBR-012 | Typed policy 有 allow/deny/require-approval outcome。 | PASS | Neutral `PolicyPort` 与 deterministic test 覆盖三个 outcome,以及 unavailable/invalid authority。 | +| CBR-013 | Permit issuance 与 execution start 要求 durable security audit。 | NOT IMPLEMENTED | Memory store 没有 audit port 或 durable issuance gate。 | +| CBR-014 | Brokered mode 禁用或 delegated cosh-core direct side-effecting tool。 | FAIL | Brokered Core integration 不存在。 | +| CBR-015 | Governed mode 下 CLI/platform operation 不能绕过 permit。 | FAIL | Governed operator integration 不存在。 | +| CBR-016 | Canonical remote target identity/attestation 已批准。 | BLOCKED | Target identity 决策与实现仍开放。 | + +## 验证证据 + +从 `src/cosh-ng` 运行: + +```text +cargo fmt --package cosh-gateway -- --check +cargo test --locked --package cosh-gateway-contracts +result: 6 integration tests passed;unit 与 doc-test target passed + +cargo test --locked --package cosh-gateway capability:: +result: 8 passed;0 failed;38 filtered out + +cargo clippy --locked --package cosh-gateway-contracts --all-targets -- -D warnings +cargo clippy --locked --package cosh-gateway --all-targets -- -D warnings +result: passed with zero warnings + +cargo doc --locked --package cosh-gateway-contracts --package cosh-gateway --no-deps +result: passed +``` + +八个测试证明: + +- Request expiry,以及 Task、Run、Actor ID、issuer、assurance、target、operation descriptor、 + 完整 operation digest 与 scope substitution 在 policy 前 fail closed; +- Policy deny 与 approval 不会创建 permit; +- Policy unavailable、revision 为零与 authority 过期 fail closed; +- Issued permit 绑定 actor、Task、Run、Execution、target、完整 canonical operation digest、policy revision、expiry + 与一次使用; +- 错误 actor、Task、Run、Execution、target、完整 operation digest 或 policy revision 不 consume authority; +- Expired 与重复 consume fail closed; +- 八个同时 claim 只有一个成功。 + +该切片没有相关 adapter,因此未运行 ECS、provider、OS mutation、network 或 ACP validation。 + +## 必要的剩余产物 + +| 产物 | 必须提供的证明 | +| --- | --- | +| Durable approval 与 re-authorization test | 只有 committed、matching approval 可以签发 no-wider permit。 | +| Immutable target-substitution matrix | Workspace、UID、boot、container 与 instance change 使 permit 失效。 | +| Durable permit/execution ledger | Restart 不能恢复 consumed authority,也不能重复 known/unknown effect。 | +| Security audit gate | Required audit persistence 失败时 issuance 与 execution start 失败。 | +| Execution kill-point 与 reconciliation matrix | Claimed、started 与 uncertain effect 绝不自动 replay。 | +| Broker bypass inventory | 每个 enabled Gateway/Core/Shell/ACP/Skill/MCP effect 都到达 verifier。 | +| Revocation 与 lease-fence corpus | Revoked、stale-runtime 与 stale-policy authority fail closed。 | +| Trusted canonicalizer test | Independent canonicalization 在 Broker admission 前绑定 descriptor 与 digest。 | + +## Exit Criteria + +1. CBR-001 至 CBR-015 全部 PASS;CBR-016 通过前禁用 remote execution。 +2. Approval resolution、permit issuance、durable consumption、audit、execution 与 reconciliation + 形成一个经过评审的 security boundary。 +3. Immutable target identity 与 runtime/lease fencing 替换初始 `TargetRef` binding。 +4. Bypass inventory 覆盖所有 enabled mutation edge,不存在 direct executor path。 +5. Crash、replay、substitution、audit-failure 与 revocation fixture 在同一个准确 commit 上通过。 + +## 剩余风险 + +- Process restart 会清空 `MemoryPermitStore`,因此它不能保护 production authority。 +- Unresolved approval 不能授权 permit;如果没有 durable matching 就增加该 path,会产生扩大 authority 的漏洞。 +- Exact `TargetRef` equality 无法发现 boot、UID、namespace、symlink 或 workspace change。 +- Caller 仍可通过当前 Core、CLI 与 platform execution path 绕过 Broker。 +- 没有 durable execution state 的 permit claim 无法 reconcile effect 后的 crash。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design.md new file mode 100644 index 0000000000..13057c22fd --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design.md @@ -0,0 +1,431 @@ +# Phase 1 Capability Broker Design + +[中文版](design_zh.md) | [Acceptance report](acceptance.md) + +## Status and decision + +This design is based on upstream commit +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. The first process-local logic slice is implemented, +but it is not a complete Phase 1 security claim. The +`CapabilityBroker` is the mandatory policy enforcement and permit authority for every OS side +effect, regardless of whether intent originates in Gateway, cosh-core, ACP, a local model, Shell, +a Skill, MCP, or a workflow. An execution target accepts work only with a valid, target-bound, +operation-bound permit. + +Approval is necessary when policy requests it but is never itself executable authority. A +committed approval may authorize the Broker to issue a narrower permit; it cannot widen actor, +target, action, resource, lifetime, or execution count. + +## Implemented process-local slice + +The Gateway now contains a provider-neutral +[`PolicyPort`](../../../../../crates/cosh-gateway/src/capability/broker.rs), +[`PermitStore`](../../../../../crates/cosh-gateway/src/capability/broker.rs), and +`CapabilityBroker`. Authorization validates request expiry, exact Task and Run parents, the +complete authenticated `ActorRef`, and an `AuthoritativeRequestBinding` before consulting policy. +The binding pins exact target, full `OperationDescriptor`, complete operation digest, and requested +scope. Actor provenance or request-content substitution therefore cannot influence policy. Policy +can deny, require approval, or allow. Zero policy revision and already-expired policy authority +fail closed. + +Direct allowance issues an `ExecutionPermit` bound to actor, Task, Run, Execution, exact +`TargetRef`, complete canonical operation digest, policy revision, expiry, and one use. The +operation digest covers namespace, name, and normalized arguments; `arguments_digest` remains +available only as narrower policy detail. A trusted ingress owns canonicalization and hashing. +The Broker never derives authority from the argument-only digest. The process-local +[`MemoryPermitStore`](../../../../../crates/cosh-gateway/src/capability/memory.rs) validates those +fields and marks a permit consumed under one mutex, so exactly one concurrent caller can claim it. +A failed binding check does not consume authority. + +This slice deliberately stops before an effect. `MemoryPermitStore` is non-durable and loses its +state on process exit. Approval produces only an `ApprovalRequest`; no durable approval ledger, +resolution lookup, or approval-based re-authorization exists, and directly allowed permits have +`approval_id = None`. Immutable target resolution, lease/runtime binding, durable permit and +execution state, audit gating, revocation, OS execution, network/ACP APIs, crash recovery, and +reconciliation remain unimplemented. + +## Goals + +- Normalize all side-effect intent into one typed `CapabilityRequest`. +- Evaluate actor, Task, Run, target, operation, resource scope, risk, and policy revision together. +- Return a stable denial, a durable approval specification, or a short-lived target-bound permit. +- Bind each permitted effect to one `ExecutionId` and a replay-safe execution ledger. +- Make opaque Shell commands fail closed and prefer deterministic typed operators. +- Correlate Task control events with the existing unified security audit contract. +- Preserve a safe local/offline policy path without weakening target or approval checks. + +## Non-goals + +- Owning the Task aggregate, channel approval UI, Agent lifecycle, PTY rendering, or provider + session. +- Treating `cosh audit check`, an approval callback, a model tool name, or a policy decision as a + permit. +- Guaranteeing exactly-once effects across a process or machine crash. +- Granting broad shell, root, filesystem, or network access because a caller is local. +- Supporting remote attestation before the Phase 0 target identity decision is accepted. +- Parsing arbitrary natural language into OS authority. + +## Current-source evidence + +| Evidence at `6c115aef` | Reusable behavior | Security gap | +| --- | --- | --- | +| [`cosh-types/audit/event.rs`](../../../../../crates/cosh-types/src/audit/event.rs) | Typed `Action`, policy `Decision`, audit identity, versioned event, and redaction shapes. | No capability request, Execution ID, target identity, or permit. | +| [`cosh-platform/audit/evaluate.rs`](../../../../../crates/cosh-platform/src/audit/evaluate.rs) | Deterministic first-match PDP with allow/deny/require-approval. | A policy result is not execution authority. | +| [`cosh-platform/audit/action.rs`](../../../../../crates/cosh-platform/src/audit/action.rs) | Shell action parsing rejects unsupported compound/metacharacter shapes. | Coverage is command-policy classification, not complete target binding. | +| [`cosh-platform/audit.rs`](../../../../../crates/cosh-platform/src/audit.rs) | Policy checks and security-boundary audit segment writes exist. | There is no permit ledger or consume protocol. | +| [`cosh-core/core.rs`](../../../../../crates/cosh-core/src/core.rs) | Hook and policy decisions, approvals, audit events, and tool execution are integrated. | Allowed tools execute inside core; approval does not pass through a common Broker. | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | `can_use_tool` includes tool, input, tool-use ID, hook flag, and audit reference. | It lacks Task/Run/target/Execution IDs and a permit. | +| [`cosh-platform/pkg.rs`](../../../../../crates/cosh-platform/src/pkg.rs), [`svc.rs`](../../../../../crates/cosh-platform/src/svc.rs), and [`checkpoint.rs`](../../../../../crates/cosh-platform/src/checkpoint.rs) | Typed OS operations and some dry-run paths exist. | Callers invoke them without a shared permit verifier. | + +At baseline, `cosh-cli`, `cosh-core`, and Shell execution paths can reach side effects without a +target-bound Broker permit. The existing policy and audit modules are foundations, not evidence +that the Broker already exists. + +## Ownership and ports + +```mermaid +flowchart LR + AR["AgentRuntimePort"] --> BP["CapabilityBrokerPort"] + GW["Gateway direct operation"] --> BP + BP --> BR["CapabilityBroker"] + BR --> PDP["PolicyDecisionPort"] + BR --> AP["ApprovalReadPort"] + BR --> PL[("Permit / execution ledger")] + BR --> AU["AuditPort"] + BR --> ET["ExecutionTargetPort"] + ET --> V["PermitVerifier"] + V --> OP["Typed operator / Shell executor"] + OP --> OS["Bound GuestOS target"] + BR --> TR["BrokerResultPort"] + TR --> TC["TaskCoordinator\nsole Task writer"] +``` + +The Broker owns capability normalization, policy orchestration, permit issuance, permit +consumption state, and execution correlation. Execution adapters own the last-mile operation and +must verify the permit immediately before use. `TaskCoordinator` owns approval state and Task +events; the Broker submits results but never writes Task storage. + +Conceptual ports are: + +```rust +trait CapabilityBrokerPort { + async fn authorize(&self, request: CapabilityRequest) + -> Result; + async fn execute(&self, request: PermittedExecution) + -> Result; + async fn reconcile(&self, execution_id: ExecutionId) + -> Result; +} + +trait PolicyDecisionPort { + async fn evaluate(&self, action: PolicyAction, context: PolicyContext) + -> Result; +} + +trait ApprovalReadPort { + async fn verified_resolution(&self, approval_id: ApprovalId) + -> Result; +} + +trait ExecutionTargetPort { + async fn execute(&self, request: VerifiedExecution) + -> Result; + async fn reconcile(&self, execution_id: ExecutionId) + -> Result; +} +``` + +The current slice implements synchronous `PolicyPort::evaluate`, `PermitStore::issue`, and +`PermitStore::consume`, plus `CapabilityBroker::authorize` and `CapabilityBroker::claim`. The +conceptual `execute`, `reconcile`, approval-read, audit, and target ports remain future boundaries. +The Broker has no Task storage or executor dependency. + +Neutral IDs and wire DTOs are implemented in the side-effect-free +`cosh-gateway-contracts` leaf. Policy adapters can +reuse `cosh-types` audit types without moving Task/Gateway contracts into `cosh-types`. + +## Capability request schema + +The implemented leaf request contains request, Task, Run and Actor identity, `TargetRef`, a +namespace/name/arguments-digest operation descriptor, a separate complete canonical operation +digest, requested resource/access scope, input digest, and expiry. Trusted ingress code must +canonicalize and hash the complete operation before constructing the request and the independent +`AuthoritativeRequestBinding`. `RequestContext` supplies the current time, parent binding, and +authoritative target/descriptor/digest/scope. The Broker compares every field before policy and +does not rebuild authority from presentation fields. +The extended target schema below remains the architecture target; runtime principal, lease fence, +effect classification, typed operation variants, and prior approval correlation are not yet +represented by the first slice. + +```text +CapabilityRequest { + request_id, task_id, run_id, tool_use_id?, + actor_context, runtime_principal, + target_ref, expected_target_kind, + operation: CapabilityOperation, + resource_scope, effect_class, + canonical_input_digest, + run_lease_fence, issued_at, deadline, + prior_approval_id? +} +``` + +`CapabilityOperation` is a closed, versioned enum for supported operations: + +```text +FileRead, FileWrite, DirectoryList, ProcessInspect, ProcessSignal, +PackageQuery, PackageInstall, PackageRemove, +ServiceQuery, ServiceStart, ServiceStop, ServiceRestart, +CheckpointList, CheckpointCreate, CheckpointRestore, +NetworkConnect, ShellCommand, PtyAttach, SkillInvoke, McpToolInvoke +``` + +Each variant carries typed fields and explicit limits. Unknown operations fail with +`unsupported_capability`; they never fall back to `ShellCommand`. `raw` strings may be retained as +bounded audit display data but are excluded from policy matching unless a specific parser has +normalized them. + +Effect classes are `Observe`, `WorkspaceWrite`, `HostMutation`, `PrivilegedMutation`, +`ExternalNetwork`, and `InteractiveControl`. Classification is an input floor: a policy may raise +risk but cannot lower a typed operation below its built-in minimum. + +## Target identity + +`TargetRef` is a user-facing selection and never appears in a permit. Before policy evaluation, +`TargetResolver` pins it to an immutable `TargetIdentity`: + +```text +TargetIdentity { + target_kind, + installation_id, + machine_or_instance_identity, + boot_or_agent_epoch, + execution_namespace, + workspace_root_identity?, + effective_uid, + platform_fingerprint +} +``` + +For a local target, identity is derived from daemon installation, pinned workspace/namespace, +machine and boot identity, and effective credentials. For a remote GuestOS target, Phase 0 must +define authenticated instance/agent epoch and replay resistance. A hostname, IP, display label, +workspace path string, channel installation, or caller-provided instance ID is insufficient. + +Target changes after authorization invalidate the decision. Symlink, mount namespace, container, +UID, boot, agent epoch, and workspace-root changes are part of target revalidation where relevant. + +The current permit binds the exact `TargetRef`, which rejects direct target substitution but does +not provide immutable target identity or attestation. No OS executor is connected while this gap +remains. + +## Decision and approval flow + +The current slice implements the first three outcomes only: deny, approval request, and permit. +Approval resolution and re-authorization are not implemented, so the later approval steps below +remain design requirements. + +The Broker returns one of: + +```text +Denied { reason_code, policy_revision } +ApprovalRequired { approval_spec, operation_digest, target_digest, expires_at } +Permitted { permit } +AlreadyExecuting { execution_id, status } +ReconciliationRequired { execution_id, reason_code } +``` + +Flow: + +1. Validate schema, actor/runtime principal, Task/Run binding, lease fence, deadline, and limits. +2. Resolve and pin `TargetIdentity`; canonicalize operation and resource scope. +3. Compute operation and target digests, then evaluate built-in risk floor and loaded policy. +4. Persist and audit denial, or return `ApprovalRequired` to `TaskCoordinator`. +5. The coordinator commits `ApprovalRequested`; presentation delivers it asynchronously. +6. The coordinator commits the first valid resolution and re-submits the same capability request + with `ApprovalId` and approval revision. +7. The Broker reads and verifies that resolution, re-resolves target and policy, then issues a + permit that is no broader and no longer-lived than the approved specification. +8. Execution consumes the permit through the ledger and invokes the target adapter. + +Policy or target changes between approval and permit issuance force re-evaluation. A more +restrictive result denies or requests new approval; an approval is never carried across a widened +scope. + +## Permit contract + +The implemented `ExecutionPermit` binds permit/request/execution IDs, actor, Task, Run, exact +target, complete operation digest, policy revision, optional approval ID, expiry, and +`single_use = true`. +It does not yet carry immutable target identity, runtime/lease fence, durable issuance timestamps, +revocation state, or cross-process integrity proof. + +```text +CapabilityPermit { + permit_schema_version, + permit_id, execution_id, + task_id, run_id, actor_id, runtime_principal, + target_identity_digest, + operation_kind, operation_digest, resource_scope_digest, + policy_revision, approval_id?, approval_revision?, + run_lease_fence, + issued_at, not_before, expires_at, + use_limit = 1, + broker_nonce, integrity_proof +} +``` + +Phase 1 local execution SHOULD use an opaque ledger-backed permit handle plus integrity proof, +rather than a self-contained broad bearer token. `PermitVerifier` validates every field, current +target identity, expiry, fence, and ledger state. Permit serialization is bounded and excludes +raw command, secret, output, or credential values. + +Invariants: + +- one permit maps to one `ExecutionId`, one exact operation digest, and one target digest; +- a permit cannot be transferred across actor, Task, Run, Runtime, target, workspace, or boot; +- a used, expired, revoked, stale-fence, malformed, or unknown permit fails closed; +- permit renewal is not supported; a fresh request and current policy produce a new permit; +- approval may narrow the requested operation but cannot issue a wildcard permit; +- target adapters never accept an unpermitted typed operation or raw shell fallback. + +## Transaction, idempotency, and execution ledger + +Authorization deduplicates by `(TaskId, RunId, RequestId, operation_digest, target_digest)`. A retry +returns the original denial, approval specification, or still-valid unconsumed permit. Reusing a +`RequestId` for another digest returns `idempotency_conflict`. + +The current memory store atomically records permit metadata for one process. It has no durable +`Ready` record or audit boundary. Production issuance must atomically record permit metadata, +`ExecutionId`, policy/approval references, expiry, and `Ready` status in a durable ledger. +Security-boundary audit persistence remains required before production authority becomes usable. + +The current `claim` validates actor, Task, Run, Execution, target, operation digest, policy +revision, expiry, and single-use state under the same mutex, then consumes the permit. The target +execution design atomically transitions `Ready -> Claimed` using permit ID, fence, and a target +executor +claim. Before the effect, it records `Started` audit evidence. The target returns a typed result +and reconciliation evidence; the ledger transitions to `Succeeded`, `Failed`, or `Uncertain`. +Repeated execute calls return the stored terminal result or `execution_in_progress`; they never +create another effect. + +A crash after `Claimed` or `Started` can leave the effect unknown. Recovery asks +`ExecutionTargetPort.reconcile(ExecutionId)`. It does not reset the permit to `Ready`. If the +target cannot prove a terminal result, status becomes `Uncertain`, the Task suspends, and an +operator-safe reconciliation decision is required. + +## Shell and typed operator rules + +Typed `cosh-platform` operations are preferred because their action and resource fields can be +bound exactly. Existing `cosh-cli` remains a user-facing envelope; the Broker SHOULD call typed +platform adapters or a narrowly defined operator protocol, not parse arbitrary CLI output to infer +authority. + +`ShellCommand` is an exceptional operation: + +- tokenize before classification, including tab/newline separators; +- reject shell metacharacters and compound/unspaced variants unless an isolated, explicit + high-risk executor contract supports them; +- bind exact argv, executable identity, cwd/workspace identity, selected environment names, + UID, timeout, output budget, and target; +- never allow a permit for a prefix, free-form continuation, or inherited interactive shell; +- require a separate `PtyAttach` permit for interactive ownership; +- fail closed when parsing, executable resolution, target pinning, or policy classification is + incomplete. + +In the brokered cosh-core profile, direct side-effecting core tools are disabled or delegated. +Current host-executed shell response support is usable only after the Bridge obtains a permit and +the execution target returns evidence. It is not a blanket approval response. + +## Security audit and Task correlation + +Task events and security audit events remain separate. The Broker emits audit events for request, +policy result, approval correlation, permit issuance/denial/revocation, execution start, terminal +result, and uncertainty. Events carry bounded `TaskId`, `RunId`, `RequestId`, `ToolUseId`, +`ExecutionId`, policy revision, target digest, result code, duration, and redaction status. + +Sensitive values are represented by digests or opaque evidence references. The existing audit +store's security-boundary durability behavior is required for permit issuance and execution +start. Best-effort audit mode cannot authorize privileged mutation in the Broker path. + +## Error model + +Stable categories include `invalid_capability`, `unsupported_capability`, `forbidden`, +`approval_required`, `approval_invalid`, `approval_expired`, `target_unresolved`, +`target_changed`, `policy_changed`, `idempotency_conflict`, `permit_expired`, `permit_revoked`, +`permit_consumed`, `permit_scope_mismatch`, `stale_lease`, `audit_unavailable`, +`execution_in_progress`, `execution_uncertain`, `target_unavailable`, and `internal`. + +Errors distinguish safe same-request retry, new authorization, new approval, target +reconciliation, and non-retryable denial. They never echo secret inputs or unbounded target +output. Transport timeout is not evidence that an effect did not occur. + +## Migration and compatibility + +1. Freeze Phase 0 capability, target identity, permit, audit correlation, and approval schemas. +2. Introduce the policy boundary and in-memory permit ledger. **Pure logic implemented; fake + target and production policy adapter remain.** +3. Add persistent permit/execution ledger and required audit boundary. +4. Route new Gateway typed operations through Broker while old direct CLI remains opt-in legacy. +5. Add brokered `CoshCoreBridge` profile with direct side-effecting core tools disabled/delegated. +6. Route Shell/ACP/Skills/MCP paths only as their adapters gain complete coverage. +7. Remove or explicitly isolate legacy bypasses only after parity and recovery acceptance passes. + +Rollback disables brokered mode and preserves existing binaries, but it also removes the new +security guarantee. A release must never advertise “all side effects governed” while any enabled +production adapter retains a direct bypass. + +## Dependencies + +- Phase 0 identity, target, capability, schema compatibility, storage, secret, and threat-model + decisions. +- [Task Execution Plane](../task-execution-plane/design.md): Task/Run state, durable approval, and + result recording. +- [Gateway API](../gateway-api/design.md): actor and direct-operation ingress. +- [Cosh Core Bridge](../cosh-core-bridge/design.md): JSONL tool-intent translation and brokered + runtime profile. +- `cosh-platform` typed operations and audit policy/storage remain implementation foundations. + +## Implementation work breakdown + +1. Define capability, target, approval reference, permit, and execution result schemas. +2. Implement target resolution/pinning and canonical operation/resource digests. +3. Adapt current audit policy evaluation with built-in minimum effect classification. + **Only the neutral policy port exists.** +4. Implement decision flow and durable approval correlation without Task writes. + **Branching exists; durable resolution and re-authorization remain.** +5. Implement permit issuance, verification, revocation, consume, and execution ledger. + **Process-local issue/claim exists; durability, revocation, and execution lifecycle remain.** +6. Implement typed local target adapters and strict Shell command/Pty paths. +7. Add required security audit events and Task correlation references. +8. Integrate Gateway and `CoshCoreBridge`, then Phase 2 ACP and presentation paths. +9. Add bypass inventory and build-time dependency/coverage checks. + +## Test strategy + +Eight current unit tests cover request expiry and parent substitution, deny and approval branches, +policy failure and invalid authority, complete permit binding, binding mismatch without +consumption, expiry/replay, and eight-way concurrent claim. The broader security suite remains: + +- Schema golden/property tests for stable digests and ID type separation. +- Table tests for built-in risk floor plus every policy decision and approval transition. +- Adversarial Shell corpus covering tabs, newlines, unspaced metacharacters, path substitution, + symlink/mount changes, environment injection, and executable replacement. +- Target substitution tests across workspace, UID, boot/agent epoch, container, and remote instance. +- Permit tests for expiry, replay, tampering, stale fence, cross-actor/Task/target use, and revoke. +- Concurrent consume tests proving one permit produces at most one claimed Execution ID. +- Kill-point tests before/after claim, audit start, OS invocation, result capture, and Task callback. +- Reconciliation tests for typed success, typed failure, in-progress, and unknown effects. +- Bypass tests proving enabled Gateway/Core/Shell/ACP/Skill/MCP mutation paths cannot reach an + executor without `PermitVerifier`. + +## Open questions + +| Question | Owner | Phase 1 default | +| --- | --- | --- | +| What is the canonical local/remote target identity? | Phase 0 identity/security | Local pinned identity only; remote blocked. | +| Is the permit opaque or signed across processes? | Broker/security | Opaque ledger-backed local handle; integrity proof at process boundary. | +| Which audit mode gates mutation? | Security/audit | Required for permit issuance and execution start. | +| Can opaque compound shell ever be permitted? | Security/executor | Deny in initial profile; prefer typed operator. | +| How is a post-crash effect reconciled? | Target owner | Per-operation typed probe; unknown suspends Task. | +| When is legacy direct CLI removed? | Product/release | After parity, recovery, and bypass inventory acceptance. | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design_zh.md new file mode 100644 index 0000000000..06094f1738 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/capability-broker/design_zh.md @@ -0,0 +1,400 @@ +# Phase 1 Capability Broker 设计 + +[English](design.md) | [验收报告](acceptance_zh.md) + +## 状态与决策 + +本文基于上游提交 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。第一版 process-local 纯逻辑 +切片已经实现,但不构成完整 Phase 1 安全声明。`CapabilityBroker` 是所有 OS side effect 必须经过的 +policy enforcement 和 permit +authority,无论 intent 来自 Gateway、cosh-core、ACP、local model、Shell、Skill、MCP 或 workflow。 +Execution target 只有收到绑定 target 和 operation 的有效 permit 才能执行。 + +Policy 要求时 approval 是必要条件,但 approval 本身不是 executable authority。Committed approval +只能允许 Broker 签发范围更窄的 permit,不能扩大 actor、target、action、resource、lifetime 或执行次数。 + +## 已实现的 process-local 切片 + +Gateway 现在包含 provider-neutral +[`PolicyPort`](../../../../../crates/cosh-gateway/src/capability/broker.rs)、 +[`PermitStore`](../../../../../crates/cosh-gateway/src/capability/broker.rs) 与 +`CapabilityBroker`。Authorization 在调用 policy 前校验 request expiry、准确的 Task/Run parent、 +完整 authenticated `ActorRef` 与 `AuthoritativeRequestBinding`。该 binding pin exact target、完整 +`OperationDescriptor`、完整 operation digest 与 requested scope。因此 Actor provenance 或 request +content substitution 不能影响 policy。Policy 可以 deny、require approval 或 allow。Revision 为零和 +已经过期的 policy authority 会 fail closed。 + +Direct allowance 签发绑定 actor、Task、Run、Execution、exact `TargetRef`、完整 canonical +operation digest、policy revision、expiry 与一次使用的 `ExecutionPermit`。Operation digest +覆盖 namespace、name 与 normalized arguments;`arguments_digest` 只作为较窄的 policy detail。 +Canonicalization 与 hashing 由 trusted ingress 负责,Broker 不使用 argument-only digest 签发 +authority。Process-local +[`MemoryPermitStore`](../../../../../crates/cosh-gateway/src/capability/memory.rs) 在同一个 mutex 内校验 +这些字段并标记 consumed,因此并发 caller 只有一个可以 claim。Binding check 失败不会 consume +authority。 + +该切片在 effect 前终止。`MemoryPermitStore` 不持久,进程退出会丢失状态。Approval 只产生 +`ApprovalRequest`;当前没有 durable approval ledger、resolution lookup 或基于 approval 的 +re-authorization,direct allow permit 的 `approval_id = None`。Immutable target resolution、 +lease/runtime binding、durable permit/execution state、audit gate、revocation、OS execution、 +network/ACP API、crash recovery 与 reconciliation 均未实现。 + +## 目标 + +- 将全部 side-effect intent 规范化为一个 typed `CapabilityRequest`。 +- 同时评估 actor、Task、Run、target、operation、resource scope、risk 和 policy revision。 +- 返回稳定 denial、持久 approval specification 或短生命周期 target-bound permit。 +- 将每个允许的 effect 绑定到一个 `ExecutionId` 和 replay-safe execution ledger。 +- Opaque Shell command fail closed,并优先使用 deterministic typed operator。 +- 将 Task control event 与现有 unified security audit contract 关联。 +- 在不降低 target 或 approval 检查的前提下保留安全 local/offline policy path。 + +## 非目标 + +- 拥有 Task aggregate、channel approval UI、Agent lifecycle、PTY rendering 或 provider session。 +- 把 `cosh audit check`、approval callback、model tool name 或 policy decision 当作 permit。 +- 保证跨进程或机器 crash 的 exactly-once effect。 +- 因 caller 在本机就授予宽泛 shell、root、filesystem 或 network access。 +- 在 Phase 0 target identity 决策接受前支持 remote attestation。 +- 将任意 natural language 解析成 OS authority。 + +## 当前源码证据 + +| `6c115aef` 的证据 | 可复用行为 | 安全缺口 | +| --- | --- | --- | +| [`cosh-types/audit/event.rs`](../../../../../crates/cosh-types/src/audit/event.rs) | Typed `Action`、policy `Decision`、audit identity、versioned event 与 redaction shape。 | 无 capability request、Execution ID、target identity 或 permit。 | +| [`cosh-platform/audit/evaluate.rs`](../../../../../crates/cosh-platform/src/audit/evaluate.rs) | Deterministic first-match PDP,输出 allow/deny/require-approval。 | Policy result 不是 execution authority。 | +| [`cosh-platform/audit/action.rs`](../../../../../crates/cosh-platform/src/audit/action.rs) | Shell action parsing 拒绝不支持的 compound/metacharacter shape。 | 只覆盖 command-policy classification,不提供完整 target binding。 | +| [`cosh-platform/audit.rs`](../../../../../crates/cosh-platform/src/audit.rs) | 已有 policy check 和 security-boundary audit segment write。 | 无 permit ledger 或 consume protocol。 | +| [`cosh-core/core.rs`](../../../../../crates/cosh-core/src/core.rs) | 集成 hook/policy decision、approval、audit event 与 tool execution。 | Allowed tool 在 core 内执行,approval 不经过公共 Broker。 | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | `can_use_tool` 携带 tool、input、tool-use ID、hook flag 与 audit reference。 | 缺少 Task/Run/target/Execution ID 和 permit。 | +| [`cosh-platform/pkg.rs`](../../../../../crates/cosh-platform/src/pkg.rs)、[`svc.rs`](../../../../../crates/cosh-platform/src/svc.rs) 与 [`checkpoint.rs`](../../../../../crates/cosh-platform/src/checkpoint.rs) | 已有 typed OS operation 与部分 dry-run path。 | Caller 无公共 permit verifier 即可调用。 | + +基线上 `cosh-cli`、`cosh-core` 和 Shell execution path 都可能在没有 target-bound Broker permit 的情况下 +触达副作用。现有 policy 与 audit module 是基础,不证明 Broker 已经存在。 + +## Ownership 与 ports + +```mermaid +flowchart LR + AR["AgentRuntimePort"] --> BP["CapabilityBrokerPort"] + GW["Gateway direct operation"] --> BP + BP --> BR["CapabilityBroker"] + BR --> PDP["PolicyDecisionPort"] + BR --> AP["ApprovalReadPort"] + BR --> PL[("Permit / execution ledger")] + BR --> AU["AuditPort"] + BR --> ET["ExecutionTargetPort"] + ET --> V["PermitVerifier"] + V --> OP["Typed operator / Shell executor"] + OP --> OS["Bound GuestOS target"] + BR --> TR["BrokerResultPort"] + TR --> TC["TaskCoordinator\nTask 唯一 writer"] +``` + +当前切片实现同步的 `PolicyPort::evaluate`、`PermitStore::issue`、`PermitStore::consume`,以及 +`CapabilityBroker::authorize` 和 `CapabilityBroker::claim`。概念模型中的 `execute`、`reconcile`、 +approval-read、audit 与 target port 仍是未来边界。Broker 不依赖 Task storage 或 executor。 + +Broker 拥有 capability normalization、policy orchestration、permit issuance、permit consumption state 和 +execution correlation。Execution adapter 拥有 last-mile operation,并在使用前立即验证 permit。 +`TaskCoordinator` 拥有 approval state 和 Task event;Broker 只提交 result,不能写 Task storage。 + +概念 ports 如下: + +```rust +trait CapabilityBrokerPort { + async fn authorize(&self, request: CapabilityRequest) + -> Result; + async fn execute(&self, request: PermittedExecution) + -> Result; + async fn reconcile(&self, execution_id: ExecutionId) + -> Result; +} + +trait PolicyDecisionPort { + async fn evaluate(&self, action: PolicyAction, context: PolicyContext) + -> Result; +} + +trait ApprovalReadPort { + async fn verified_resolution(&self, approval_id: ApprovalId) + -> Result; +} + +trait ExecutionTargetPort { + async fn execute(&self, request: VerifiedExecution) + -> Result; + async fn reconcile(&self, execution_id: ExecutionId) + -> Result; +} +``` + +中立 ID 与 wire DTO 已位于 side-effect-free `cosh-gateway-contracts` leaf。Policy adapter 可以复用 +`cosh-types` audit type,但不能把 Task/Gateway contract 移入 `cosh-types`。 + +## Capability request schema + +已实现 leaf request 包含 request、Task、Run 与 Actor identity、`TargetRef`、由 +namespace/name/arguments-digest 组成的 operation descriptor、独立的完整 canonical operation digest、 +requested resource/access scope、input digest 与 expiry。Trusted ingress 必须在构造 request 和独立 +`AuthoritativeRequestBinding` 前 canonicalize 并 hash 完整 operation。`RequestContext` 提供 current +time、parent binding 与 authoritative target/descriptor/digest/scope。Broker 在 policy 前逐项比较, +不从 presentation field 重建 authority。下面的扩展 +target schema 仍是目标架构;runtime principal、lease fence、effect classification、typed operation +variant 与 prior approval correlation 尚未进入第一版切片。 + +```text +CapabilityRequest { + request_id, task_id, run_id, tool_use_id?, + actor_context, runtime_principal, + target_ref, expected_target_kind, + operation: CapabilityOperation, + resource_scope, effect_class, + canonical_input_digest, + run_lease_fence, issued_at, deadline, + prior_approval_id? +} +``` + +`CapabilityOperation` 是 supported operation 的 closed、versioned enum: + +```text +FileRead, FileWrite, DirectoryList, ProcessInspect, ProcessSignal, +PackageQuery, PackageInstall, PackageRemove, +ServiceQuery, ServiceStart, ServiceStop, ServiceRestart, +CheckpointList, CheckpointCreate, CheckpointRestore, +NetworkConnect, ShellCommand, PtyAttach, SkillInvoke, McpToolInvoke +``` + +每个 variant 携带 typed field 与显式 limit。Unknown operation 返回 `unsupported_capability`,不能 fallback +为 `ShellCommand`。Raw string 可以作为有界 audit display data 保留,但只有经过专用 parser 规范化后才能 +参与 policy matching。 + +Effect class 为 `Observe`、`WorkspaceWrite`、`HostMutation`、`PrivilegedMutation`、 +`ExternalNetwork` 和 `InteractiveControl`。Classification 是风险下限;policy 可以提高风险,但不能把 typed +operation 降到 built-in minimum 以下。 + +## Target identity + +`TargetRef` 只供用户选择,不能写入 permit。Policy evaluation 前,`TargetResolver` 将其 pin 为不可变 +`TargetIdentity`: + +```text +TargetIdentity { + target_kind, + installation_id, + machine_or_instance_identity, + boot_or_agent_epoch, + execution_namespace, + workspace_root_identity?, + effective_uid, + platform_fingerprint +} +``` + +Local target identity 由 daemon installation、pinned workspace/namespace、machine/boot identity 和 effective +credential 得出。Remote GuestOS target 必须由 Phase 0 定义 authenticated instance/agent epoch 与 replay +resistance。Hostname、IP、display label、workspace path string、channel installation 或 caller 提供的 instance +ID 都不充分。 + +Authorization 后 target 发生变化会使 decision 失效。相关场景下 symlink、mount namespace、container、 +UID、boot、agent epoch 和 workspace-root 变化都属于 target revalidation。 + +当前 permit 绑定 exact `TargetRef`,可以拒绝直接 target substitution,但不能提供 immutable target +identity 或 attestation。在该缺口关闭前不连接 OS executor。 + +## Decision 与 approval flow + +当前切片只实现前三种结果,也就是 deny、approval request 与 permit。Approval resolution 和 +re-authorization 未实现,所以下述后续 approval 步骤仍是设计要求。 + +Broker 返回以下之一: + +```text +Denied { reason_code, policy_revision } +ApprovalRequired { approval_spec, operation_digest, target_digest, expires_at } +Permitted { permit } +AlreadyExecuting { execution_id, status } +ReconciliationRequired { execution_id, reason_code } +``` + +流程如下: + +1. 校验 schema、actor/runtime principal、Task/Run binding、lease fence、deadline 与 limit。 +2. Resolve 并 pin `TargetIdentity`,canonicalize operation 和 resource scope。 +3. 计算 operation/target digest,再评估 built-in risk floor 与 loaded policy。 +4. 持久并 audit denial,或把 `ApprovalRequired` 返回给 `TaskCoordinator`。 +5. Coordinator commit `ApprovalRequested`,presentation 异步投递。 +6. Coordinator commit 第一个有效 resolution,并携带 `ApprovalId` 与 approval revision 重新提交同一 + capability request。 +7. Broker 读取并验证 resolution,重新解析 target 和 policy,再签发不比 approved specification 更宽或 + 更长的 permit。 +8. Execution 通过 ledger consume permit,并调用 target adapter。 + +Approval 与 permit issuance 之间 policy 或 target 变化时必须重新评估。更严格结果会 deny 或请求新 +approval;approval 不能跨 widened scope 沿用。 + +## Permit contract + +已实现 `ExecutionPermit` 绑定 permit/request/execution ID、actor、Task、Run、exact target、 +完整 operation digest、policy revision、optional approval ID、expiry 与 `single_use = true`。它尚未携带 +immutable target identity、runtime/lease fence、durable issuance timestamp、revocation state 或 +cross-process integrity proof。 + +```text +CapabilityPermit { + permit_schema_version, + permit_id, execution_id, + task_id, run_id, actor_id, runtime_principal, + target_identity_digest, + operation_kind, operation_digest, resource_scope_digest, + policy_revision, approval_id?, approval_revision?, + run_lease_fence, + issued_at, not_before, expires_at, + use_limit = 1, + broker_nonce, integrity_proof +} +``` + +Phase 1 local execution 应使用 opaque ledger-backed permit handle 加 integrity proof,避免 self-contained +宽泛 bearer token。`PermitVerifier` 校验全部字段、当前 target identity、expiry、fence 与 ledger state。 +Permit serialization 有界,不包含 raw command、secret、output 或 credential value。 + +约束如下: + +- 一个 permit 映射一个 `ExecutionId`、一个 exact operation digest 和一个 target digest; +- Permit 不能跨 actor、Task、Run、Runtime、target、workspace 或 boot 转移; +- Used、expired、revoked、stale-fence、malformed 或 unknown permit fail closed; +- 不支持 permit renewal;fresh request 和 current policy 生成新 permit; +- Approval 可以缩小 requested operation,但不能签发 wildcard permit; +- Target adapter 不接受无 permit 的 typed operation 或 raw shell fallback。 + +## Transaction、idempotency 与 execution ledger + +Authorization 按 `(TaskId, RunId, RequestId, operation_digest, target_digest)` 去重。Retry 返回原 denial、 +approval specification 或仍有效且未 consume 的 permit。同一 `RequestId` 用于另一 digest 时返回 +`idempotency_conflict`。 + +Permit issuance 原子记录 permit metadata、`ExecutionId`、policy/approval reference、expiry 和 `Ready` +status。Authority 可用前必须持久化 security-boundary audit;audit failure 会 deny issuance。 + +Execution 使用 permit ID、fence 和 target executor claim 原子执行 `Ready -> Claimed`。副作用前记录 +`Started` audit evidence。Target 返回 typed result 与 reconciliation evidence;ledger 转为 `Succeeded`、 +`Failed` 或 `Uncertain`。重复 execute call 返回 stored terminal result 或 `execution_in_progress`,不能创建 +另一个 effect。 + +在 `Claimed` 或 `Started` 后 crash 可能使 effect unknown。Recovery 调用 +`ExecutionTargetPort.reconcile(ExecutionId)`,不能把 permit reset 为 `Ready`。Target 无法证明 terminal +result 时,status 变为 `Uncertain`,Task suspend,并要求 operator-safe reconciliation decision。 + +## Shell 与 typed operator 规则 + +优先使用 typed `cosh-platform` operation,因为 action 和 resource field 可以精确 binding。现有 +`cosh-cli` 是 user-facing envelope;Broker 应调用 typed platform adapter 或窄 operator protocol,不能 +解析任意 CLI output 推断 authority。 + +`ShellCommand` 是例外 operation: + +- Classification 前 tokenize,并支持 tab/newline separator; +- 拒绝 shell metacharacter 和 compound/unspaced variant,除非 isolated、explicit high-risk executor + contract 明确支持; +- 绑定 exact argv、executable identity、cwd/workspace identity、选中的 environment name、UID、timeout、 + output budget 和 target; +- 不允许 prefix、free-form continuation 或 inherited interactive shell permit; +- Interactive ownership 必须使用独立 `PtyAttach` permit; +- Parsing、executable resolution、target pinning 或 policy classification 不完整时 fail closed。 + +Brokered cosh-core profile 中,direct side-effecting core tool 必须禁用或 delegated。现有 host-executed +shell response 只有在 Bridge 获得 permit 且 execution target 返回 evidence 后才可使用,不能作为 blanket +approval response。 + +## Security audit 与 Task correlation + +Task event 与 security audit event 保持分离。Broker 为 request、policy result、approval correlation、permit +issuance/denial/revocation、execution start、terminal result 与 uncertainty 生成 audit event。Event 携带有界 +`TaskId`、`RunId`、`RequestId`、`ToolUseId`、`ExecutionId`、policy revision、target digest、result code、 +duration 与 redaction status。 + +Sensitive value 使用 digest 或 opaque evidence reference。Permit issuance 与 execution start 必须使用现有 +audit store 的 security-boundary durability behavior。Best-effort audit mode 不能在 Broker path 授权 +privileged mutation。 + +## Error model + +稳定分类包括 `invalid_capability`、`unsupported_capability`、`forbidden`、`approval_required`、 +`approval_invalid`、`approval_expired`、`target_unresolved`、`target_changed`、`policy_changed`、 +`idempotency_conflict`、`permit_expired`、`permit_revoked`、`permit_consumed`、 +`permit_scope_mismatch`、`stale_lease`、`audit_unavailable`、`execution_in_progress`、 +`execution_uncertain`、`target_unavailable` 和 `internal`。 + +Error 区分 safe same-request retry、new authorization、new approval、target reconciliation 和 non-retryable +denial,不能回显 secret input 或无界 target output。Transport timeout 不能证明 effect 没有发生。 + +## 迁移与兼容 + +1. 固化 Phase 0 capability、target identity、permit、audit correlation 与 approval schema。 +2. 引入 policy boundary 与 in-memory permit ledger。**Pure logic 已实现;fake target 与 production + policy adapter 尚未完成。** +3. 增加 persistent permit/execution ledger 和 required audit boundary。 +4. 新 Gateway typed operation 通过 Broker;旧 direct CLI 作为 opt-in legacy 暂时保留。 +5. 增加 brokered `CoshCoreBridge` profile,禁用或 delegated direct side-effecting core tool。 +6. Shell/ACP/Skills/MCP path 只有在 adapter coverage 完整后才接入。 +7. Parity 与 recovery acceptance 通过后,删除或显式隔离 legacy bypass。 + +Rollback 禁用 brokered mode 并保留现有 binary,但同时会移除新安全保证。只要 enabled production +adapter 仍有 direct bypass,release 就不能宣传“all side effects governed”。 + +## 依赖 + +- Phase 0 identity、target、capability、schema compatibility、storage、secret 与 threat-model 决策。 +- [Task Execution Plane](../task-execution-plane/design_zh.md):Task/Run state、durable approval 与 result + recording。 +- [Gateway API](../gateway-api/design_zh.md):actor 与 direct-operation ingress。 +- [Cosh Core Bridge](../cosh-core-bridge/design_zh.md):JSONL tool-intent translation 与 brokered runtime + profile。 +- `cosh-platform` typed operation 和 audit policy/storage 继续作为 implementation foundation。 + +## 实现任务分解 + +1. 定义 capability、target、approval reference、permit 与 execution result schema。 +2. 实现 target resolution/pinning 与 canonical operation/resource digest。 +3. 使用 built-in minimum effect classification 适配当前 audit policy evaluation。 + **当前只有 neutral policy port。** +4. 实现 decision flow 与 durable approval correlation,但不能写 Task。 + **Branching 已实现;durable resolution 与 re-authorization 尚未完成。** +5. 实现 permit issuance、verification、revocation、consume 与 execution ledger。 + **Process-local issue/claim 已实现;durability、revocation 与 execution lifecycle 尚未完成。** +6. 实现 typed local target adapter 和严格 Shell command/Pty path。 +7. 增加 required security audit event 与 Task correlation reference。 +8. 集成 Gateway 与 `CoshCoreBridge`,再接 Phase 2 ACP 和 presentation path。 +9. 增加 bypass inventory 和 build-time dependency/coverage check。 + +## 测试策略 + +当前八个 unit test 覆盖 request expiry 与 parent substitution、deny 与 approval branch、policy failure 与 +invalid authority、完整 permit binding、binding mismatch 不消费、expiry/replay,以及八路 concurrent +claim。仍需更广的 security suite: + +- Stable digest 和 ID type separation 的 schema golden/property test。 +- Built-in risk floor、每个 policy decision 与 approval transition 的 table test。 +- Adversarial Shell corpus 覆盖 tab、newline、unspaced metacharacter、path substitution、symlink/mount + change、environment injection 与 executable replacement。 +- 覆盖 workspace、UID、boot/agent epoch、container 与 remote instance 的 target substitution test。 +- Permit expiry、replay、tamper、stale fence、cross-actor/Task/target use 与 revoke 测试。 +- Concurrent consume test 证明一个 permit 最多产生一个 claimed Execution ID。 +- 在 claim、audit start、OS invocation、result capture 和 Task callback 前后执行 kill-point test。 +- Typed success、typed failure、in-progress 与 unknown effect 的 reconciliation test。 +- Bypass test 证明 enabled Gateway/Core/Shell/ACP/Skill/MCP mutation path 没有 `PermitVerifier` 就不能到达 + executor。 + +## 开放问题 + +| 问题 | Owner | Phase 1 默认值 | +| --- | --- | --- | +| Canonical local/remote target identity 是什么? | Phase 0 identity/security | 只支持 local pinned identity;remote blocked。 | +| 跨进程 permit 使用 opaque 还是 signed? | Broker/security | Local 使用 opaque ledger-backed handle;进程边界使用 integrity proof。 | +| 哪种 audit mode 可以授权 mutation? | Security/audit | Permit issuance 与 execution start 必须 Required。 | +| 是否允许 opaque compound shell? | Security/executor | Initial profile deny;优先 typed operator。 | +| Post-crash effect 如何 reconcile? | Target owner | 每种 operation 使用 typed probe;unknown 则 suspend Task。 | +| 何时删除 legacy direct CLI? | Product/release | Parity、recovery 和 bypass inventory 验收后。 | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance.md new file mode 100644 index 0000000000..3f12c9edcf --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance.md @@ -0,0 +1,123 @@ +# Phase 1 Cosh Core Bridge Acceptance Baseline + +[中文版](acceptance_zh.md) | [Design](design.md) + +## Baseline result + +**Overall: PARTIAL foundation on a working tree based on +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`.** The pinned upstream baseline remains NOT +IMPLEMENTED. The planning branch adds a Gateway-owned local process supervisor and strict private +cosh-core JSONL v1 codec, but no integrated `CoshCoreBridge`, durable runtime binding, public event +mapping, brokered execution profile, or Shell ownership migration exists. + +## Result vocabulary + +| Result | Meaning | +| --- | --- | +| PASS | Baseline evidence satisfies a reusable or final criterion exactly. | +| PARTIAL | A scoped foundation is implemented and tested, but integration or required failure evidence is absent. | +| FAIL | Current behavior contradicts the target production invariant. | +| NOT IMPLEMENTED | The required Gateway path does not exist. | +| BLOCKED | A named prerequisite decision prevents validation. | + +## Evidence inspected + +- Pinned source: `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. +- [`protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) defines exact private protocol v1 + and all current message shapes. +- [`headless.rs`](../../../../../crates/cosh-core/src/headless.rs) negotiates and runs provider turns. +- [`session.rs`](../../../../../crates/cosh-core/src/session.rs) and + [`session/store.rs`](../../../../../crates/cosh-core/src/session/store.rs) persist provider + conversations. +- [`cosh_core_service.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs) owns + the current Shell persistent process and cancellation lifecycle. +- [`control_protocol.rs`](../../../../../crates/cosh-shell/src/adapter/control_protocol.rs) mirrors + parser/serializer behavior inside standalone Shell. +- [`runtime/supervisor.rs`](../../../../../crates/cosh-gateway/src/runtime/supervisor.rs) owns one + child process group, bounded pipes, TERM/KILL escalation, reap, and process terminal delivery. +- [`runtime/bounded_io.rs`](../../../../../crates/cosh-gateway/src/runtime/bounded_io.rs) implements + bounded stdout framing and stderr-tail retention. +- [`runtime/cosh_core_jsonl.rs`](../../../../../crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs) + implements strict private v1 initialization and typed wire observations without ACP naming. + +## Acceptance matrix + +| ID | Criterion | Baseline | Evidence or missing artifact | +| --- | --- | --- | --- | +| CCB-001 | Bridge implements neutral `AgentRuntimePort`. | NOT IMPLEMENTED | No Gateway/port. | +| CCB-002 | Private JSONL v1 is explicitly distinct from ACP v1. | PASS | Current runtime contract states the separation. | +| CCB-003 | Exact initialization succeeds before Task input admission. | PARTIAL | Codec requires exact v1/correlation/capabilities before user frames; Task admission is not integrated. | +| CCB-004 | Gateway production rejects legacy unversioned peers. | PARTIAL | Codec rejects missing/mismatched versions; no launch profile invokes it yet. | +| CCB-005 | `RuntimeSupervisor` solely owns child process lifecycle. | PARTIAL | New supervisor owns one child/group/pipes/reap; existing Shell core owner and restart policy are not migrated. | +| CCB-006 | Every JSONL message maps to a bounded ordered Runtime event/command. | PARTIAL | Current outputs decode to typed local observations; public contract mapping/order/backpressure are absent. | +| CCB-007 | Task/Run/runtime/Agent/provider IDs remain distinct. | PARTIAL | Contracts own neutral IDs and codec names provider session separately; no binding mapper exists. | +| CCB-008 | Bridge never writes Task storage. | NOT IMPLEMENTED | Boundary absent. | +| CCB-009 | Brokered profile prevents core-local side effects. | FAIL | Current allowed/approved tools can execute in core. | +| CCB-010 | `can_use_tool` reaches Broker and a permit-bound target result. | NOT IMPLEMENTED | Broker/Bridge absent. | +| CCB-011 | Approval receipt follows durable Task ownership. | NOT IMPLEMENTED | Current receipt proves Shell main-thread receipt only. | +| CCB-012 | Question/auth/evidence use durable or secret-safe ports. | NOT IMPLEMENTED | Current paths are Shell-owned. | +| CCB-013 | Process cancel escalates, kills the group, and reaps children. | PARTIAL | Supervisor TERM/KILL/reap test passes; descendant, cancel/result/EOF race, and protocol interrupt fixtures remain. | +| CCB-014 | Provider session persists separately from Task storage. | PASS | Current `SessionStore` is workspace-scoped provider state. | +| CCB-015 | Crash/restart never silently resends an uncertain prompt. | NOT IMPLEMENTED | Task/Broker reconciliation absent. | +| CCB-016 | Gateway has no Rust dependency on core implementation or Shell. | PASS | `cosh-gateway` speaks mirrored private wire types and has no core/Shell crate dependency. | +| CCB-017 | Brokered tool inventory and private-protocol extension decision are frozen. | BLOCKED | Core/Broker owner decision pending. | + +PASS entries for current Shell behavior are reusable baseline evidence, not proof that the future +Gateway-owned path exists. + +## Required fixtures, commands, and artifacts + +| Artifact | Required proof | +| --- | --- | +| `cosh-jsonl-v1` canonical corpus | Every input/output, optional capability, malformed and oversized case. | +| Cross-implementation fixture report | Core encoder, Shell mirror, and Gateway decoder agree. | +| `runtime-supervisor-killpoints` | Spawn, negotiate, stream, cancel, EOF, wait, shutdown, restart races. | +| `runtime-event-mapping` goldens | Bounded normalized events and ID correlation for every message. | +| `brokered-tool-inventory` | Every exposed side-effecting tool delegates or is disabled. | +| Provider-session recovery matrix | New, resume, mismatch, corrupt, stale, cancel, restart. | +| Backpressure fixture | Durable sink outage never drops control or terminal events. | + +Expected scoped commands after implementation are: + +```bash +cargo test --package cosh-gateway cosh_core_bridge +cargo test --package cosh-gateway runtime_supervisor +cargo test --package cosh-gateway cosh_jsonl_contract +cargo test --package cosh-gateway-contracts runtime_schema +``` + +First-increment targeted evidence: + +```bash +cargo test -p cosh-gateway --lib runtime --no-fail-fast +# 19 passed; 0 failed; 17 filtered out +``` + +This covers codec negotiation/terminal behavior, bounds, launch validation, stderr retention, +single terminal delivery, and TERM-to-KILL reap. It does not replace the required canonical, +process-tree/race, public mapping, broker, recovery, backpressure, Shell protocol, or PTY gates. +The process suite also injects process-group TERM failure and proves that the direct child is +killed, reaped, settled, and exposed through one still-readable terminal before any repeat read. +Eighteen passing tests are Runtime-owned; the `runtime` name filter also selects one Task aggregate +test whose name mentions runtime events. + +## Exit criteria + +1. CCB-001 through CCB-016 are PASS and CCB-017 has an accepted profile/version decision. +2. Canonical fixture, mapping, process-race, session-recovery, Broker bypass, and backpressure suites + pass at the exact candidate commit with recorded counts. +3. A dependency check proves Gateway does not link the core implementation or standalone Shell, + and the Bridge/RuntimeSupervisor cannot write Task storage or execute OS work outside Broker. +4. Security review covers executable/workspace pinning, environment allowlist, protocol parser + limits, correlation, secret/auth flow, provider session scope, approval receipt timing, + cancellation, and uncertain execution. +5. The report records executable/profile configuration, private protocol version, exact commands, + fixtures, unsupported tools, restart policy, untested real-provider paths, and rollback. + +## Current risks + +- Reusing Shell `AgentAdapter` types would import presentation and CommandBlock coupling. +- Calling private JSONL “ACP” would create false interoperability and version assumptions. +- Sending generic allow for a side-effect tool bypasses target-bound permits. +- Persisting a provider session binding from a stale Run can attach future work to the wrong Task. +- Reading faster than durable Task event commit can lose control events on daemon crash. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance_zh.md new file mode 100644 index 0000000000..26577dc200 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/acceptance_zh.md @@ -0,0 +1,119 @@ +# Phase 1 Cosh Core Bridge 验收基线 + +[English](acceptance.md) | [设计](design_zh.md) + +## 基线结果 + +**整体结果:基于 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 的工作树已有 PARTIAL +foundation。** 固定的上游基线仍是 NOT IMPLEMENTED。当前规划分支加入 Gateway-owned local process +supervisor 与严格的 private cosh-core JSONL v1 codec,但仍不存在已集成的 `CoshCoreBridge`、durable +runtime binding、public event mapping、brokered execution profile 或 Shell ownership migration。 + +## 结果口径 + +| 结果 | 含义 | +| --- | --- | +| PASS | 基线证据准确满足可复用或最终验收项。 | +| PARTIAL | 已实现并测试局部基础,但仍缺少集成或必要 failure evidence。 | +| FAIL | 当前行为违反目标 production invariant。 | +| NOT IMPLEMENTED | 所需 Gateway path 不存在。 | +| BLOCKED | 指定 prerequisite 决策阻止验证。 | + +## 已检查证据 + +- 固定源码:`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。 +- [`protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) 定义 exact private protocol v1 和全部当前 + message shape。 +- [`headless.rs`](../../../../../crates/cosh-core/src/headless.rs) negotiation 并运行 provider turn。 +- [`session.rs`](../../../../../crates/cosh-core/src/session.rs) 和 + [`session/store.rs`](../../../../../crates/cosh-core/src/session/store.rs) 持久化 provider conversation。 +- [`cosh_core_service.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs) 拥有当前 Shell + persistent process 与 cancellation lifecycle。 +- [`control_protocol.rs`](../../../../../crates/cosh-shell/src/adapter/control_protocol.rs) 在 standalone Shell + 内 mirror parser/serializer behavior。 +- [`runtime/supervisor.rs`](../../../../../crates/cosh-gateway/src/runtime/supervisor.rs) 独占一个 child + process group、有界 pipe、TERM/KILL escalation、reap 与 process terminal delivery。 +- [`runtime/bounded_io.rs`](../../../../../crates/cosh-gateway/src/runtime/bounded_io.rs) 实现 bounded + stdout framing 与 stderr-tail retention。 +- [`runtime/cosh_core_jsonl.rs`](../../../../../crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs) 实现严格的 + private v1 initialization 与 typed wire observation,不使用 ACP 命名。 + +## 验收矩阵 + +| ID | 验收项 | 基线 | 证据或缺失产物 | +| --- | --- | --- | --- | +| CCB-001 | Bridge 实现 neutral `AgentRuntimePort`。 | NOT IMPLEMENTED | 无 Gateway/port。 | +| CCB-002 | Private JSONL v1 与 ACP v1 显式分离。 | PASS | 当前 runtime contract 明确该区分。 | +| CCB-003 | Task input admission 前 exact initialization 成功。 | PARTIAL | Codec 在 user frame 前要求 exact v1/correlation/capabilities;尚未集成 Task admission。 | +| CCB-004 | Gateway production 拒绝 legacy unversioned peer。 | PARTIAL | Codec 拒绝 missing/mismatched version;尚无 launch profile 调用。 | +| CCB-005 | `RuntimeSupervisor` 是 child process lifecycle 唯一 owner。 | PARTIAL | 新 supervisor 独占一个 child/group/pipe/reap;现有 Shell core owner 与 restart policy 尚未迁移。 | +| CCB-006 | 每种 JSONL message 映射成有界有序 Runtime event/command。 | PARTIAL | 当前 output 解码为 typed local observation;缺少 public contract mapping/order/backpressure。 | +| CCB-007 | Task/Run/runtime/Agent/provider ID 保持独立。 | PARTIAL | Contracts 拥有 neutral ID,codec 单独命名 provider session;尚无 binding mapper。 | +| CCB-008 | Bridge 不能写 Task storage。 | NOT IMPLEMENTED | Boundary 不存在。 | +| CCB-009 | Brokered profile 阻止 core-local side effect。 | FAIL | 当前 allowed/approved tool 可在 core 执行。 | +| CCB-010 | `can_use_tool` 进入 Broker 和 permit-bound target result。 | NOT IMPLEMENTED | Broker/Bridge 不存在。 | +| CCB-011 | Approval receipt 在 durable Task ownership 后发送。 | NOT IMPLEMENTED | 当前 receipt 只证明 Shell main-thread receipt。 | +| CCB-012 | Question/auth/evidence 使用 durable 或 secret-safe port。 | NOT IMPLEMENTED | 当前 path 属于 Shell。 | +| CCB-013 | Process cancel escalation、kill group 并 reap child。 | PARTIAL | Supervisor TERM/KILL/reap test 通过;仍缺 descendant、cancel/result/EOF race 与 protocol interrupt fixture。 | +| CCB-014 | Provider session persistence 与 Task storage 分离。 | PASS | 当前 `SessionStore` 是 workspace-scoped provider state。 | +| CCB-015 | Crash/restart 不会静默重发 uncertain prompt。 | NOT IMPLEMENTED | Task/Broker reconciliation 不存在。 | +| CCB-016 | Gateway 不通过 Rust dependency 依赖 core implementation 或 Shell。 | PASS | `cosh-gateway` mirror private wire type,不依赖 core/Shell crate。 | +| CCB-017 | Brokered tool inventory 与 private-protocol extension 决策已固化。 | BLOCKED | Core/Broker owner 决策未完成。 | + +当前 Shell behavior 的 PASS 只表示可复用 baseline evidence,不证明未来 Gateway-owned path 已存在。 + +## 要求的 fixture、命令与产物 + +| 产物 | 必须提供的证明 | +| --- | --- | +| `cosh-jsonl-v1` canonical corpus | 每种 input/output、optional capability、malformed 与 oversized case。 | +| Cross-implementation fixture report | Core encoder、Shell mirror 与 Gateway decoder 一致。 | +| `runtime-supervisor-killpoints` | Spawn、negotiate、stream、cancel、EOF、wait、shutdown 与 restart race。 | +| `runtime-event-mapping` golden | 每种 message 的有界 normalized event 与 ID correlation。 | +| `brokered-tool-inventory` | 每个 exposed side-effecting tool 都 delegated 或 disabled。 | +| Provider-session recovery matrix | New、resume、mismatch、corrupt、stale、cancel 与 restart。 | +| Backpressure fixture | Durable sink outage 不会丢 control 或 terminal event。 | + +实现后预期执行: + +```bash +cargo test --package cosh-gateway cosh_core_bridge +cargo test --package cosh-gateway runtime_supervisor +cargo test --package cosh-gateway cosh_jsonl_contract +cargo test --package cosh-gateway-contracts runtime_schema +``` + +第一轮增量的 targeted evidence: + +```bash +cargo test -p cosh-gateway --lib runtime --no-fail-fast +# 19 passed; 0 failed; 17 filtered out +``` + +这覆盖 codec negotiation/terminal behavior、bound、launch validation、stderr retention、single terminal +delivery 与 TERM-to-KILL reap。它不能替代必需的 canonical、process-tree/race、public mapping、Broker、 +recovery、backpressure、Shell protocol 或 PTY gate。 +Process suite 还注入 process-group TERM failure,并证明 direct child 会在返回前被 kill、reap、settle, +同时保留一个仍可读取且不可重复交付的 terminal。 +其中 18 个通过测试由 Runtime 拥有;`runtime` 名称过滤还会选中一个名称包含 runtime event 的 Task +aggregate test。 + +## Exit criteria + +1. CCB-001 至 CCB-016 全部 PASS,且 CCB-017 有 accepted profile/version decision。 +2. Canonical fixture、mapping、process-race、session-recovery、Broker bypass 与 backpressure suite 在 exact + candidate commit 上通过并记录 count。 +3. Dependency check 证明 Gateway 不 link core implementation 或 standalone Shell,并且 Bridge/ + RuntimeSupervisor 不能写 Task storage,或绕过 Broker 执行 OS 工作。 +4. Security review 覆盖 executable/workspace pinning、environment allowlist、protocol parser limit、 + correlation、secret/auth flow、provider session scope、approval receipt timing、cancel 与 uncertain execution。 +5. 报告记录 executable/profile configuration、private protocol version、exact command、fixture、unsupported + tool、restart policy、untested real-provider path 与 rollback。 + +## 当前风险 + +- 复用 Shell `AgentAdapter` type 会引入 presentation 与 CommandBlock coupling。 +- 把 private JSONL 称作“ACP”会产生虚假 interoperability 与 version assumption。 +- 对 side-effect tool 发送 generic allow 会绕过 target-bound permit。 +- 从 stale Run 持久化 provider session binding,可能使后续工作关联到错误 Task。 +- 读取速度超过 durable Task event commit,可能在 daemon crash 时丢失 control event。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design.md new file mode 100644 index 0000000000..9f7d77b082 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design.md @@ -0,0 +1,384 @@ +# Phase 1 Cosh Core Bridge Design + +[中文版](design_zh.md) | [Acceptance baseline](acceptance.md) + +## Status and decision + +This Phase 1 plan is based on upstream commit +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`; it is not an implementation claim. `CoshCoreBridge` +adapts the existing private cosh-core newline-delimited JSONL control protocol to the neutral +`AgentRuntimePort`. It does not rename, wrap, or describe that protocol as ACP. ACP v1 is a +separate Phase 2 `AcpClientBridge` with different methods, capabilities, versioning, and wire +semantics. + +`RuntimeSupervisor` is the only owner of cosh-core and future ACP/provider child processes. The +Bridge owns protocol translation and per-runtime correlation; it never writes Task storage, +decides policy, renders approval UI, or executes an OS action directly. + +## Goals + +- Reuse cosh-core's implemented provider, session, streaming, tool, question, auth, cancellation, + and recovery behavior behind a channel-neutral Runtime Port. +- Negotiate the private control protocol before admitting a Task Run. +- Preserve distinct Task, Run, runtime instance, Agent session, provider session, request, tool, + and execution identities. +- Normalize JSONL output into bounded, ordered `AgentRuntimeEvent` values. +- Route side-effecting tool intent through `CapabilityBroker`; never answer it with an ungoverned + generic approval. +- Supervise process groups, stderr, deadlines, cancellation, shutdown, and terminal result exactly + once per runtime attempt. +- Keep the current direct Shell/core path available during opt-in migration. + +## Non-goals + +- Implementing or exposing ACP, JSON-RPC, HTTP, a Gateway API, or a channel protocol. +- Making `ProviderSessionId` a `TaskId`, `RunId`, or `AgentSessionId`. +- Moving Task durability into cosh-core `SessionStore`. +- Sharing `cosh-shell` UI/runtime state with the daemon through a Rust dependency. +- Allowing cosh-core to execute side-effecting tools internally in the brokered production profile. +- Supporting concurrent turns on one cosh-core process in Phase 1. +- Persisting raw stdout/stderr, secrets, prompt bodies, or terminal buffers in Task events. + +## Current-source evidence + +| Evidence at `6c115aef` | Reusable behavior | Bridge gap | +| --- | --- | --- | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | Private JSONL `InputMessage`/`OutputMessage`, exact `CONTROL_PROTOCOL_VERSION = 1`, capabilities, approvals, questions, auth, evidence, and results. | No `AgentRuntimePort`, Task/Run identity, or Broker contract. | +| [`cosh-core/headless.rs`](../../../../../crates/cosh-core/src/headless.rs) | Headless loop, strict version mismatch exit, provider session setup, turn persistence, and terminal results. | Lifecycle is tied to stdin/stdout and caller process ownership. | +| [`cosh-core/session.rs`](../../../../../crates/cosh-core/src/session.rs) | Workspace-scoped `ProviderSessionId` and versioned conversation persistence. | Provider session is not durable Task state. | +| [`cosh-shell/adapter/cosh_core_service.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs) | Long-lived child, one active request, interrupt/graceful kill, registry reuse, bounded cancellation artifacts, and reset. | Owned by standalone Shell and not reusable by Gateway. | +| [`cosh-shell/adapter/control_protocol.rs`](../../../../../crates/cosh-shell/src/adapter/control_protocol.rs) | Shell-side parser/serializer and capability negotiation mirror. | Types are Shell-owned and include presentation/shell assumptions. | +| [`cosh-shell/adapter/cosh_core.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core.rs) | Workspace, resume, approval mode, prompt, and AgentEvent adaptation. | `AgentRequest` includes Shell command context and state is in memory. | +| [`runtime-contracts.md`](../../../runtime-contracts.md) | Documents current implemented shell/core runtime contract and explicitly separates ACP/Task designs. | No Gateway-owned bridge exists. | + +The baseline has no `cosh-gateway`, `RuntimeSupervisor`, neutral `AgentRuntimePort`, +`CoshCoreBridge`, durable runtime binding, or brokered core launch profile. + +## First implementation increment + +The planning branch now contains a deliberately runtime-local foundation under +[`cosh-gateway/src/runtime.rs`](../../../../../crates/cosh-gateway/src/runtime.rs). It does not +complete `CoshCoreBridge` or change the baseline assessment above: + +- [`RuntimeSupervisor`](../../../../../crates/cosh-gateway/src/runtime/supervisor.rs) validates an + absolute direct executable and pinned workspace, clears inherited environment, owns piped + stdin/stdout/stderr, creates a dedicated process group, escalates TERM to KILL, reaps the child, + and delivers one process terminal observation. Its state machine currently covers `Idle`, + `Starting`, `Initializing`, `Ready`, `Stopping`, and `Exited`. +- [`bounded_io.rs`](../../../../../crates/cosh-gateway/src/runtime/bounded_io.rs) bounds stdout + JSONL frames before full-line allocation and continuously drains a fixed-capacity stderr tail + with an explicit discarded-byte count. +- [`cosh_core_jsonl.rs`](../../../../../crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs) is a + pure codec for **private COSH control protocol v1**. It emits explicit initialization, requires + exact version/correlation/capabilities, permits only bounded auth bootstrap before readiness, + decodes current system/stream/assistant/tool/control/registry/result shapes into runtime-local + observations, and synthesizes EOF-without-result once. +- Public Task, Run, Runtime, and Agent IDs/events remain owned by `cosh-gateway-contracts`. The + codec intentionally does not copy those types or label private COSH JSONL as ACP. A subsequent + bridge increment must attach contract headers, binding fences, sequence, correlation, and + backpressure while converting observations to `AgentRuntimeEvent`. + +The increment does not yet implement restart budgets, deadline classes, durable binding, public +event mapping, provider-session resume validation, broker integration, or Shell ownership +migration. `cosh-shell` PTY and its compatibility core path are unchanged. + +## Ownership and dependencies + +```mermaid +flowchart LR + TC["TaskCoordinator"] --> ARP["AgentRuntimePort"] + ARP --> CCB["CoshCoreBridge"] + CCB --> RS["RuntimeSupervisor\nsole child owner"] + RS --> CORE["cosh-core child"] + CORE <--> J["private COSH JSONL v1"] + J <--> CCB + CCB --> RES["RuntimeEventSink"] + CCB --> CB["CapabilityBrokerPort"] + CCB --> AS["Approval / Input ports"] + RES --> TC +``` + +`RuntimeSupervisor` owns executable resolution, child creation, process group, stdin/stdout/stderr, +resource limits, health, kill/reap, and restart policy. `CoshCoreBridge` owns JSONL codecs, +negotiated capabilities, correlation maps, runtime binding, and event normalization. The Task +Coordinator decides Run state and is the only Task writer. The Broker owns side-effect authority. + +Planned dependency direction: + +```text +cosh-gateway -> cosh-gateway-contracts +cosh-gateway -> cosh-platform -> cosh-types +cosh-core -> cosh-platform -> cosh-types +cosh-shell remains standalone +``` + +There is no Rust dependency from `cosh-gateway` to the cosh-core implementation crate, from +`cosh-core` back to Gateway, or between Gateway and `cosh-shell`. The bridge launches the binary +and speaks the private JSONL contract. Canonical JSON fixtures are mirrored across core, Shell, +and Gateway tests to detect drift. Neutral Runtime IDs/events follow the Phase 0 G0 schema-first +decision and planned side-effect-free `cosh-gateway-contracts` leaf. + +## Agent Runtime Port + +Conceptual commands are: + +```text +InspectCapabilities { runtime_profile } +Start { task_id, run_id, run_lease_fence, workspace, target_ref, + runtime_profile, input_ref, idempotency_key } +Resume { task_id, run_id, run_lease_fence, agent_session_id, input_ref } +SendInput { task_id, run_id, request_id, input_ref } +ResolvePermission { task_id, run_id, request_id, resolution_ref } +Cancel { task_id, run_id, request_id, reason } +Close { agent_session_id, reason } +Subscribe { runtime_binding, after_cursor } +``` + +The Bridge returns an `AgentRuntimeBinding` with Gateway-created `RuntimeInstanceId` and +`AgentSessionId`, plus an opaque provider binding that may contain `ProviderSessionId`. Callers +never receive a provider ID as a Task or Run identity. + +Representative events are: + +```text +RuntimeStarting, RuntimeReady, AgentSessionBound, +AgentStatusChanged, AgentMessageChunk, AgentMessageCompleted, +ToolUseDeclared, CapabilityRequested, ApprovalOwnershipConfirmed, +UserInputRequested, AuthInputRequested, ShellEvidenceRequested, +ToolResultRecorded, EnvironmentDeltaProposed, +RuntimeTurnSucceeded, RuntimeTurnFailed, RuntimeCancelled, +RuntimeProcessExited, RuntimeProtocolFailed +``` + +Every command/event carries Task ID, Run ID, runtime instance ID, Run lease fence, bridge sequence, +and causation/correlation IDs where applicable. + +## Private JSONL profile + +### Initialization + +Before a user message, the Bridge sends a correlated `control_request.initialize` with +`protocol_version: 1` and the appropriate `fire_session_start` value. It waits for the matching +`control_response`, requires exact protocol version `1`, and snapshots advertised capabilities. +A missing version is legacy-compatible in current core, but the Gateway production profile +requires explicit version and capabilities. Current headless startup may request authentication +before it consumes initialization, so one bounded `auth_required` bootstrap exchange is allowed +through the secret-safe credential port. No Task user turn is admitted during that exchange. +Mismatch, malformed response, any other output before negotiation, or deadline expiry terminates +the runtime attempt before input admission. + +`CONTROL_PROTOCOL_VERSION = 1` is a private COSH protocol constant. It is not ACP v1 and is never +selected based on an ACP SDK version. + +### Input mapping + +| Runtime command | Private JSONL message | +| --- | --- | +| Start/Resume prompt | `type: user` with content, provider session binding, and bounded Shell context when enabled | +| Cancel | correlated `control_request.interrupt`, then supervisor escalation | +| Close | `control_request.shutdown`, bounded grace, then kill/reap | +| Runtime config update | typed `config_override`, `switch_model`, or `reload_config` only when profile allows | +| Permission result | `control_response` correlated to original core `request_id` | +| Durable approval ownership | `approval_receipt` only when capability is advertised | +| Registry management | `registry_request`; never interleave with an active turn in Phase 1 | + +The Bridge does not accept caller-created raw JSONL. It constructs messages from typed Runtime +commands and validates all bounded fields. + +### Output mapping + +| Private output | Normalized handling | +| --- | --- | +| `system/init` | Validate/bind provider session, model, tool inventory, and resumability. | +| `system/status` and hook notifications | Bounded status/governance events. | +| `stream_event` | Ordered text/thinking/tool-input deltas with per-process sequence. | +| `assistant` / `user` | Completed content/tool-result events; deduplicate by scoped IDs. | +| `control_request.can_use_tool` | Normalize and submit to `CapabilityBrokerPort`. | +| `control_request.ask_user` | Ask Task Coordinator to enter `WaitingInput`. | +| `control_request.auth_required` | Request a credential reference through a dedicated secret-safe port or suspend. | +| `control_request.shell_evidence` | Use a bounded evidence-read capability; never read arbitrary host data in Bridge. | +| `result` | Emit exactly one terminal Runtime event, then persist provider binding metadata. | +| `registry_response` | Complete only the correlated management request. | + +Unknown top-level types, invalid field types, oversized lines/nesting, unmatched responses, reused +request IDs, terminal output followed by new turn data, or capability violations fail the runtime +attempt. Unknown optional payload fields may be retained only as bounded diagnostics. + +## Brokered execution profile + +Current cosh-core can execute an `Outcome::Allow` tool internally and can execute an approved tool +after receiving a generic allow response. That behavior is incompatible with the production +Gateway invariant for side effects. Phase 1 therefore adds a distinct brokered launch profile, +while keeping direct legacy mode unchanged. + +The brokered profile must: + +1. expose only an audited allowlist of tools; +2. disable direct file-write/edit/process/network/MCP side-effecting tools unless they have a + governed host-execution response contract; +3. force every exposed side-effect-capable Shell operation through `can_use_tool`; +4. never send a generic allow for a side-effecting operation; +5. obtain a Broker permit and execute through `ExecutionTargetPort`; +6. send `host_executed_shell` only after the permitted target returns bounded evidence; +7. fail the tool call if permit, target, audit, or result delivery is uncertain. + +The first profile may expose safe model-only/read-only tools plus governed Shell execution. Any +core tool that cannot delegate execution stays disabled. If a private JSONL extension is required +for additional hosted tools, its control protocol version and canonical fixtures must change +explicitly; it still does not become ACP. + +## Approval, receipt, question, auth, and evidence semantics + +For `can_use_tool`, the Bridge constructs a `CapabilityRequest` carrying Task, Run, actor, target, +tool-use ID, core request ID, canonical input, and lease fence. Broker denial produces a correlated +deny response. `ApprovalRequired` is first committed by `TaskCoordinator`; only after that commit +may the Bridge send `approval_receipt`, proving durable ownership rather than merely UI rendering. + +After the first valid approval resolution, the Broker re-evaluates and may issue a permit. The +Bridge executes through the target and sends the exact correlated result. Timeout, cancellation, +stale fence, expired approval, audit failure, or unknown execution fails closed. Late callbacks do +not send a second response. + +`ask_user` becomes durable `WaitingInput`; a presenter answer returns through the coordinator and +is correlated once. `auth_required` never stores secret values in Task events or Bridge logs. A +credential port returns an opaque reference or the Run suspends for configured authentication. +`shell_evidence` uses a scoped, bounded read contract and returns evidence references/text under +the negotiated capability; the Bridge cannot access a live Shell buffer directly. + +## Process supervision and lifecycle + +`RuntimeSupervisor` applies one lifecycle policy to cosh-core, ACP, and other provider children: + +- resolve an approved executable and arguments without invoking a shell; +- start a dedicated process group with pinned workspace and bounded environment allowlist; +- own stdin, bounded line decoder, bounded stderr tail, and child wait handle; +- allow one active turn per cosh-core runtime instance in Phase 1; +- negotiate before admission and reject output before readiness except the explicit bounded auth + bootstrap required by current headless startup; +- enforce startup, idle/progress, approval, turn, cancellation, and shutdown deadlines separately; +- on cancel, send `interrupt`, wait a bounded grace, terminate the process group, and reap every + child before declaring the runtime settled; +- emit one terminal process event even when stdout EOF, wait status, and cancellation race; +- use restart backoff/budget and create a new `RuntimeInstanceId` after restart. + +A child PID, EOF, broken pipe, or dropped subscription is not a Task terminal state. Supervisor +events go to the coordinator, which decides suspend, retry, fail, or confirm cancellation. + +## Session and identity semantics + +The provider session remains owned by cosh-core `SessionStore` and scoped to a canonical +workspace. The Bridge maps it into opaque binding metadata under one `AgentSessionId`. It may +start core with `--resume ` only after validation and exact workspace match. + +Required invariants: + +- `TaskId != RunId != RuntimeInstanceId != AgentSessionId != ProviderSessionId`; +- one active Run owns one runtime turn and Run lease fence; +- a provider session commit after a stale/cancelled Run cannot rebind the Task; +- retry creates a new Run and runtime attempt unless an explicit, validated resume policy applies; +- restart never silently resends a prompt whose OS effect may be uncertain; +- `env_delta` is a proposed normalized event, not permission to mutate Gateway or target process + environment. + +## Ordering, idempotency, replay, and backpressure + +The Bridge assigns `(RuntimeInstanceId, bridge_sequence)` as each valid line/update is accepted. +Core request IDs and tool-use IDs provide scoped deduplication for control/tool flows. Stream +chunks without source IDs are append-once within one live decoder; after process loss the Run +suspends rather than fabricating exact replay. + +Task event commit acknowledgment provides backpressure. The Bridge uses bounded queues and pauses +stdout consumption within safe OS pipe limits; if durable consumers remain unavailable, it +cancels/terminates the runtime rather than dropping control, permission, tool result, or terminal +events. Presentation detach does not affect the runtime subscription owned by the Task Plane. + +A duplicate Task command with the same Runtime idempotency key returns the existing binding or +status. A conflicting payload fails. Repeated cancel/close is idempotent. Exactly one correlated +response is sent for each pending core request; resolved IDs enter a bounded tombstone set to +reject late duplicates. + +## Error model + +Stable categories include `runtime_not_found`, `spawn_failed`, `protocol_mismatch`, +`protocol_malformed`, `capability_missing`, `unexpected_message`, `message_too_large`, +`correlation_unknown`, `correlation_duplicate`, `runtime_busy`, `provider_session_invalid`, +`workspace_mismatch`, `broker_denied`, `approval_expired`, `execution_uncertain`, +`credential_unavailable`, `event_sink_backpressure`, `cancel_timeout`, `process_exited`, and +`shutdown_timeout`. + +Errors include bounded stderr classification, exit status, runtime instance, and protocol phase, +but never raw secrets, prompts, full provider payloads, or terminal output. Recoverability is a +Task policy decision informed by the error class. A bridge timeout or transport loss never claims +that an OS effect did not occur. + +## Migration and compatibility + +1. Freeze private JSONL fixtures and neutral Runtime contracts in Phase 0. +2. Implement `RuntimeSupervisor` and fake line-protocol child under `cosh-gateway`. +3. Implement `CoshCoreBridge` codecs, negotiation, event normalization, correlation, and session + binding without Broker execution. +4. Add brokered launch profile with only non-effecting/delegated tools and integrate the Broker. +5. Connect Task Run leases, cancellation, durable approval/input, replay cursor, and projections. +6. Keep current direct Shell/core adapter as legacy; Shell later attaches through Gateway wire + client/mirror and canonical fixtures, without a crate dependency. +7. Add Phase 2 ACP bridge as a sibling Runtime adapter, never as a mode inside CoshCoreBridge. + +Rollback disables Gateway runtime mode and leaves current cosh-core/Shell behavior and provider +session files intact. A private protocol extension requires explicit versioning and coordinated +core/Shell/Gateway fixture changes; version 1 is not silently reinterpreted. + +## Dependencies + +- Phase 0 G0 schema/contracts, process supervision, provider trust, secret, and storage decisions. +- [Task Execution Plane](../task-execution-plane/design.md): Run lease, runtime binding, durable + event, input, approval, cancellation, and terminal state. +- [Capability Broker](../capability-broker/design.md): every side-effect tool decision and target + execution. +- [Gateway API](../gateway-api/design.md): user-facing commands only through Task Coordinator. +- Existing cosh-core protocol/session code and cosh-shell fixtures as implementation evidence, not + shared domain ownership. + +## Implementation work breakdown + +1. Inventory/freeze all private JSONL inputs, outputs, limits, and canonical fixtures. +2. Define neutral Runtime commands/events/bindings in schema-first contracts. +3. Implement reusable `RuntimeSupervisor` process-group, I/O, deadline, kill/reap, and restart + lifecycle. +4. Implement strict bounded JSONL codec, v1 negotiation, correlation table, and tombstones. +5. Map system/stream/assistant/tool/question/auth/evidence/result messages to Runtime events. +6. Implement provider session binding/resume validation without Task-store writes. +7. Implement brokered core profile and `CapabilityBrokerPort`/target result flow. +8. Integrate Task lease/cancel/backpressure and add migration-compatible Shell mirror fixtures. +9. Add protocol drift, crash, malformed stream, race, and security bypass tests. + +Current progress: the first increment implements the process state/launch/process-group/bounded +I/O/terminal foundation from item 3 and the strict initialization plus typed wire-observation +foundation from item 4. Items 1-2 remain separate contract/corpus work; items 5-9 are not complete. + +## Test strategy + +- Canonical cross-implementation fixtures for every JSONL type and capability combination. +- Strict negotiation tests for explicit v1, legacy missing version rejection in production, + mismatch, wrong request ID, duplicate initialize, permitted auth bootstrap, and all other + output-before-ready. +- Parser fuzzing for oversized lines, nesting, invalid UTF-8, partial JSON, unknown tags, and EOF. +- Mapping golden tests for status, chunks, tool calls/results, approval, question, auth, evidence, + environment delta, and every terminal result/error. +- Process tests for spawn failure, process-group descendants, stderr bound, broken pipe, cancel/ + result/EOF races, shutdown escalation, reap, and restart budget. +- Session tests for workspace mismatch, stale Run commit, validated resume, corrupt provider + session, and retry without prompt replay. +- Broker bypass tests proving no side-effecting exposed tool receives generic allow or core-local + execution in brokered mode. +- Backpressure/crash tests proving control and terminal events are never silently dropped. + +## Open questions + +| Question | Owner | Phase 1 default | +| --- | --- | --- | +| Is one persistent core reused across Tasks? | Runtime owner | One active turn; reuse only after clean settlement and profile/workspace validation. | +| Which core tools are exposed in brokered profile? | Core/Broker owners | Only audited non-effecting or host-delegated tools. | +| Does brokered profile require private protocol v2? | Core/Bridge owners | Avoid if safe via launch profile; version explicitly if wire changes. | +| How are credentials supplied? | Secret/security owner | Opaque credential reference; no Task/event secret values. | +| What is the maximum durable event lag? | Runtime/Task owners | Benchmark bounded queue; cancel safely before control-event loss. | +| Can a failed turn resume the provider session? | Runtime/product owners | Only with validated session and explicit no-uncertain-effect policy. | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design_zh.md new file mode 100644 index 0000000000..73bc745f38 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/cosh-core-bridge/design_zh.md @@ -0,0 +1,360 @@ +# Phase 1 Cosh Core Bridge 设计 + +[English](design.md) | [验收基线](acceptance_zh.md) + +## 状态与决策 + +本文基于上游提交 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 规划 Phase 1,不代表功能已经 +实现。`CoshCoreBridge` 将现有 private cosh-core newline-delimited JSONL control protocol 适配到中立 +`AgentRuntimePort`。不能把该协议重命名、包装或描述为 ACP。ACP v1 是 Phase 2 单独的 +`AcpClientBridge`,具有不同 method、capability、versioning 与 wire semantic。 + +`RuntimeSupervisor` 是 cosh-core 与未来 ACP/provider child process 的唯一 owner。Bridge 拥有 protocol +translation 和 per-runtime correlation;不能写 Task storage、决定 policy、渲染 approval UI 或直接执行 +OS action。 + +## 目标 + +- 在 channel-neutral Runtime Port 后复用 cosh-core 已实现的 provider、session、streaming、tool、 + question、auth、cancellation 和 recovery 行为。 +- 接受 Task Run 前完成 private control protocol negotiation。 +- 保持 Task、Run、runtime instance、Agent session、provider session、request、tool 与 execution identity + 相互独立。 +- 将 JSONL output 规范化成有界、有序的 `AgentRuntimeEvent`。 +- 将 side-effecting tool intent 送入 `CapabilityBroker`,不能用 ungoverned generic approval 回答。 +- 每个 runtime attempt 只由一处监督 process group、stderr、deadline、cancel、shutdown 与 terminal result。 +- Opt-in migration 期间保留当前 direct Shell/Core path。 + +## 非目标 + +- 实现或暴露 ACP、JSON-RPC、HTTP、Gateway API 或 channel protocol。 +- 将 `ProviderSessionId` 用作 `TaskId`、`RunId` 或 `AgentSessionId`。 +- 将 Task durability 移入 cosh-core `SessionStore`。 +- 通过 Rust dependency 与 daemon 共享 `cosh-shell` UI/runtime state。 +- 允许 cosh-core 在 brokered production profile 内部执行 side-effecting tool。 +- Phase 1 在单个 cosh-core process 上运行 concurrent turn。 +- 在 Task event 中持久化 raw stdout/stderr、secret、prompt body 或 terminal buffer。 + +## 当前源码证据 + +| `6c115aef` 的证据 | 可复用行为 | Bridge 缺口 | +| --- | --- | --- | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | Private JSONL `InputMessage`/`OutputMessage`、exact `CONTROL_PROTOCOL_VERSION = 1`、capability、approval、question、auth、evidence 与 result。 | 无 `AgentRuntimePort`、Task/Run identity 或 Broker contract。 | +| [`cosh-core/headless.rs`](../../../../../crates/cosh-core/src/headless.rs) | Headless loop、严格 version mismatch exit、provider session setup、turn persistence 与 terminal result。 | Lifecycle 依赖 stdin/stdout 和 caller process ownership。 | +| [`cosh-core/session.rs`](../../../../../crates/cosh-core/src/session.rs) | Workspace-scoped `ProviderSessionId` 与 versioned conversation persistence。 | Provider session 不是 durable Task state。 | +| [`cosh-shell/adapter/cosh_core_service.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core_service.rs) | Long-lived child、单 active request、interrupt/graceful kill、registry reuse、有界 cancellation artifact 与 reset。 | 由 standalone Shell 拥有,Gateway 无法复用。 | +| [`cosh-shell/adapter/control_protocol.rs`](../../../../../crates/cosh-shell/src/adapter/control_protocol.rs) | Shell-side parser/serializer 与 capability negotiation mirror。 | Type 由 Shell 拥有并包含 presentation/shell assumption。 | +| [`cosh-shell/adapter/cosh_core.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core.rs) | Workspace、resume、approval mode、prompt 与 AgentEvent adaptation。 | `AgentRequest` 包含 Shell command context,state 在内存。 | +| [`runtime-contracts.md`](../../../runtime-contracts.md) | 记录当前 implemented Shell/Core runtime contract,并明确 ACP/Task design 分离。 | 不存在 Gateway-owned bridge。 | + +基线上不存在 `cosh-gateway`、`RuntimeSupervisor`、neutral `AgentRuntimePort`、`CoshCoreBridge`、durable +runtime binding 或 brokered core launch profile。 + +## 第一轮实现增量 + +当前规划分支已在 +[`cosh-gateway/src/runtime.rs`](../../../../../crates/cosh-gateway/src/runtime.rs) 下加入严格限定在 +runtime-local 边界内的基础实现。它不表示 `CoshCoreBridge` 已完成,也不改变上面的基线结论: + +- [`RuntimeSupervisor`](../../../../../crates/cosh-gateway/src/runtime/supervisor.rs) 验证 absolute + direct executable 与 pinned workspace,清除 inherited environment,独占 piped + stdin/stdout/stderr,创建 dedicated process group,将 TERM 升级为 KILL,reap child,并且只交付一次 + process terminal observation。当前状态机覆盖 `Idle`、`Starting`、`Initializing`、`Ready`、 + `Stopping` 与 `Exited`。 +- [`bounded_io.rs`](../../../../../crates/cosh-gateway/src/runtime/bounded_io.rs) 在整行分配前限制 stdout + JSONL frame,并持续 drain 固定容量的 stderr tail,同时明确记录 discarded byte 数量。 +- [`cosh_core_jsonl.rs`](../../../../../crates/cosh-gateway/src/runtime/cosh_core_jsonl.rs) 是 **private + COSH control protocol v1** 的纯 codec。它发送显式 initialization,要求 exact + version/correlation/capabilities,readiness 前只允许有界 auth bootstrap,将当前 + system/stream/assistant/tool/control/registry/result shape 解码为 runtime-local observation,并且只生成 + 一次 EOF-without-result。 +- Public Task、Run、Runtime 与 Agent ID/event 继续由 `cosh-gateway-contracts` 拥有。Codec 不复制这些 + type,也不会把 private COSH JSONL 称为 ACP。后续 Bridge 增量必须在 observation 转换为 + `AgentRuntimeEvent` 时附加 contract header、binding fence、sequence、correlation 与 backpressure。 + +该增量尚未实现 restart budget、deadline 分类、durable binding、public event mapping、provider-session +resume validation、Broker 集成或 Shell ownership migration。`cosh-shell` PTY 与 compatibility core path +均未修改。 + +## Ownership 与 dependencies + +```mermaid +flowchart LR + TC["TaskCoordinator"] --> ARP["AgentRuntimePort"] + ARP --> CCB["CoshCoreBridge"] + CCB --> RS["RuntimeSupervisor\nchild 唯一 owner"] + RS --> CORE["cosh-core child"] + CORE <--> J["private COSH JSONL v1"] + J <--> CCB + CCB --> RES["RuntimeEventSink"] + CCB --> CB["CapabilityBrokerPort"] + CCB --> AS["Approval / Input ports"] + RES --> TC +``` + +`RuntimeSupervisor` 拥有 executable resolution、child creation、process group、stdin/stdout/stderr、 +resource limit、health、kill/reap 与 restart policy。`CoshCoreBridge` 拥有 JSONL codec、negotiated +capability、correlation map、runtime binding 与 event normalization。Task Coordinator 决定 Run state, +并是 Task 唯一 writer。Broker 拥有 side-effect authority。 + +计划的依赖方向: + +```text +cosh-gateway -> cosh-gateway-contracts +cosh-gateway -> cosh-platform -> cosh-types +cosh-core -> cosh-platform -> cosh-types +cosh-shell remains standalone +``` + +不存在 `cosh-gateway` 到 cosh-core implementation crate 的 Rust dependency,也不存在 `cosh-core` +反向依赖 Gateway,或 Gateway 与 `cosh-shell` 之间的 crate dependency。Bridge 启动 binary 并使用 private +JSONL contract。Core、Shell 与 Gateway test 通过 canonical JSON fixture mirror 检测 drift。Neutral +Runtime ID/event 遵循 Phase 0 G0 schema-first 决策和计划中的 side-effect-free +`cosh-gateway-contracts` leaf。 + +## Agent Runtime Port + +概念命令为: + +```text +InspectCapabilities { runtime_profile } +Start { task_id, run_id, run_lease_fence, workspace, target_ref, + runtime_profile, input_ref, idempotency_key } +Resume { task_id, run_id, run_lease_fence, agent_session_id, input_ref } +SendInput { task_id, run_id, request_id, input_ref } +ResolvePermission { task_id, run_id, request_id, resolution_ref } +Cancel { task_id, run_id, request_id, reason } +Close { agent_session_id, reason } +Subscribe { runtime_binding, after_cursor } +``` + +Bridge 返回 `AgentRuntimeBinding`,其中包含 Gateway 创建的 `RuntimeInstanceId` 和 `AgentSessionId`,以及 +可能含 `ProviderSessionId` 的 opaque provider binding。Caller 不能把 provider ID 当作 Task 或 Run identity。 + +代表性 event 为: + +```text +RuntimeStarting, RuntimeReady, AgentSessionBound, +AgentStatusChanged, AgentMessageChunk, AgentMessageCompleted, +ToolUseDeclared, CapabilityRequested, ApprovalOwnershipConfirmed, +UserInputRequested, AuthInputRequested, ShellEvidenceRequested, +ToolResultRecorded, EnvironmentDeltaProposed, +RuntimeTurnSucceeded, RuntimeTurnFailed, RuntimeCancelled, +RuntimeProcessExited, RuntimeProtocolFailed +``` + +每个 command/event 携带 Task ID、Run ID、runtime instance ID、Run lease fence、bridge sequence,以及适用 +的 causation/correlation ID。 + +## Private JSONL profile + +### Initialization + +发送 user message 前,Bridge 发送相关联的 `control_request.initialize`,携带 +`protocol_version: 1` 和适当 `fire_session_start`。Bridge 等待 matching `control_response`,要求 exact +protocol version `1`,并 snapshot advertised capability。当前 core 对 missing version 保持 legacy +compatibility,但 Gateway production profile 要求 explicit version 和 capability。当前 headless startup 可能 +在消费 initialization 前请求 authentication,因此允许通过 secret-safe credential port 完成一次有界 +`auth_required` bootstrap exchange,期间不能接收 Task user turn。Mismatch、malformed response、其他 +negotiation 前 output 或 deadline expiry 都会在 input admission 前结束 runtime attempt。 + +`CONTROL_PROTOCOL_VERSION = 1` 是 private COSH protocol constant,不是 ACP v1,也不能根据 ACP SDK +version 选择。 + +### Input mapping + +| Runtime command | Private JSONL message | +| --- | --- | +| Start/Resume prompt | `type: user`,携带 content、provider session binding 与启用时的有界 Shell context | +| Cancel | 相关联的 `control_request.interrupt`,随后 supervisor escalation | +| Close | `control_request.shutdown`、有界 grace、然后 kill/reap | +| Runtime config update | 只有 profile 允许时使用 typed `config_override`、`switch_model` 或 `reload_config` | +| Permission result | 与原 core `request_id` 相关联的 `control_response` | +| Durable approval ownership | 只有 capability advertised 时发送 `approval_receipt` | +| Registry management | `registry_request`;Phase 1 不与 active turn interleave | + +Bridge 不接受 caller 构造的 raw JSONL,只根据 typed Runtime command 构造 message,并校验全部有界 field。 + +### Output mapping + +| Private output | 规范化处理 | +| --- | --- | +| `system/init` | 校验并绑定 provider session、model、tool inventory 与 resumability。 | +| `system/status` 和 hook notification | 有界 status/governance event。 | +| `stream_event` | 使用 per-process sequence 的 ordered text/thinking/tool-input delta。 | +| `assistant` / `user` | Completed content/tool-result event;按 scoped ID 去重。 | +| `control_request.can_use_tool` | 规范化后提交 `CapabilityBrokerPort`。 | +| `control_request.ask_user` | 请求 Task Coordinator 进入 `WaitingInput`。 | +| `control_request.auth_required` | 通过 secret-safe dedicated port 请求 credential reference,或 suspend。 | +| `control_request.shell_evidence` | 使用有界 evidence-read capability;Bridge 不能读取任意 host data。 | +| `result` | 只发一个 terminal Runtime event,再保存 provider binding metadata。 | +| `registry_response` | 只完成 correlated management request。 | + +Unknown top-level type、非法 field type、超大 line/nesting、unmatched response、request ID reuse、terminal +output 后的新 turn data 或 capability violation 都使 runtime attempt fail。Unknown optional payload field 只能 +作为有界 diagnostic 保留。 + +## Brokered execution profile + +当前 cosh-core 可以在内部执行 `Outcome::Allow` tool,也可以在收到 generic allow response 后执行 approved +tool。这与生产 Gateway 的 side-effect 约束不兼容。因此 Phase 1 增加独立 brokered launch profile,同时 +保持 direct legacy mode 不变。 + +Brokered profile 必须: + +1. 只暴露经过 audit 的 tool allowlist; +2. 禁用 direct file-write/edit/process/network/MCP side-effecting tool,除非存在 governed host-execution + response contract; +3. 强制每个 exposed side-effect-capable Shell operation 经过 `can_use_tool`; +4. 不对 side-effecting operation 发送 generic allow; +5. 获得 Broker permit 并通过 `ExecutionTargetPort` 执行; +6. 只有 permitted target 返回有界 evidence 后才发送 `host_executed_shell`; +7. Permit、target、audit 或 result delivery 无法确定时让 tool call fail。 + +首个 profile 可以暴露 safe model-only/read-only tool 和 governed Shell execution。无法 delegated execution +的 core tool 保持 disabled。如果额外 hosted tool 需要 private JSONL extension,必须显式修改 control +protocol version 和 canonical fixture;该协议仍不能变成 ACP。 + +## Approval、receipt、question、auth 与 evidence 语义 + +Bridge 为 `can_use_tool` 构造携带 Task、Run、actor、target、tool-use ID、core request ID、canonical input +和 lease fence 的 `CapabilityRequest`。Broker denial 产生 correlated deny response。`ApprovalRequired` 首先由 +`TaskCoordinator` commit;commit 后 Bridge 才能发送 `approval_receipt`,证明 durable ownership,而不是 +UI 已渲染。 + +第一个有效 approval resolution 后,Broker 重新评估并可能签发 permit。Bridge 通过 target 执行并发送 exact +correlated result。Timeout、cancel、stale fence、expired approval、audit failure 或 unknown execution 均 fail +closed。Late callback 不能发送第二个 response。 + +`ask_user` 成为 durable `WaitingInput`;presenter answer 通过 coordinator 返回且只 correlation 一次。 +`auth_required` 不能在 Task event 或 Bridge log 存 secret value。Credential port 返回 opaque reference, +否则 Run 因 authentication 配置 suspend。`shell_evidence` 使用 scoped、有界 read contract,在 negotiated +capability 下返回 evidence reference/text;Bridge 不能直接访问 live Shell buffer。 + +## Process supervision 与 lifecycle + +`RuntimeSupervisor` 对 cosh-core、ACP 和其他 provider child 使用一套 lifecycle policy: + +- 不调用 shell,解析 approved executable 与 argument; +- 使用 pinned workspace 和有界 environment allowlist 启动 dedicated process group; +- 拥有 stdin、bounded line decoder、bounded stderr tail 和 child wait handle; +- Phase 1 每个 cosh-core runtime instance 只允许一个 active turn; +- Admission 前 negotiate;除当前 headless startup 明确需要的有界 auth bootstrap 外,readiness 前 output + 直接拒绝; +- 分别强制 startup、idle/progress、approval、turn、cancellation 和 shutdown deadline; +- Cancel 时发送 `interrupt`,等待有界 grace,terminate process group 并 reap 所有 child,之后才认为 + runtime settled; +- Stdout EOF、wait status 与 cancel race 时仍只发一个 terminal process event; +- 使用 restart backoff/budget,restart 后创建新 `RuntimeInstanceId`。 + +Child PID、EOF、broken pipe 或 dropped subscription 都不是 Task terminal state。Supervisor event 发送给 +coordinator,由 coordinator 决定 suspend、retry、fail 或 confirm cancellation。 + +## Session 与 identity 语义 + +Provider session 继续由 cosh-core `SessionStore` 拥有,并按 canonical workspace 隔离。Bridge 将其映射 +为一个 `AgentSessionId` 下的 opaque binding metadata。只有 validation 通过且 workspace exact match 时, +才可以用 `--resume ` 启动 core。 + +必要约束: + +- `TaskId != RunId != RuntimeInstanceId != AgentSessionId != ProviderSessionId`; +- 一个 active Run 拥有一个 runtime turn 和 Run lease fence; +- Stale/cancelled Run 后的 provider session commit 不能 rebind Task; +- Retry 创建新 Run 和 runtime attempt,除非 explicit、validated resume policy 允许; +- Restart 不能静默重发可能具有 uncertain OS effect 的 prompt; +- `env_delta` 是 proposed normalized event,不是修改 Gateway 或 target process environment 的权限。 + +## Ordering、idempotency、replay 与 backpressure + +Bridge 在接受每个合法 line/update 时分配 `(RuntimeInstanceId, bridge_sequence)`。Core request ID 与 tool-use +ID 为 control/tool flow 提供 scoped deduplication。没有 source ID 的 stream chunk 只在一个 live decoder 内 +append-once;process 丢失后 Run suspend,不能伪造 exact replay。 + +Task event commit acknowledgment 提供 backpressure。Bridge 使用 bounded queue,并在安全 OS pipe limit 内 +暂停读取 stdout;如果 durable consumer 持续不可用,则 cancel/terminate runtime,不能丢弃 control、 +permission、tool result 或 terminal event。Presentation detach 不影响 Task Plane 拥有的 runtime subscription。 + +相同 Runtime idempotency key 的 duplicate Task command 返回 existing binding/status;payload conflict 则失败。 +Repeated cancel/close 幂等。每个 pending core request 只发送一个 correlated response;resolved ID 进入有界 +tombstone set,从而拒绝 late duplicate。 + +## Error model + +稳定分类包括 `runtime_not_found`、`spawn_failed`、`protocol_mismatch`、`protocol_malformed`、 +`capability_missing`、`unexpected_message`、`message_too_large`、`correlation_unknown`、 +`correlation_duplicate`、`runtime_busy`、`provider_session_invalid`、`workspace_mismatch`、 +`broker_denied`、`approval_expired`、`execution_uncertain`、`credential_unavailable`、 +`event_sink_backpressure`、`cancel_timeout`、`process_exited` 和 `shutdown_timeout`。 + +Error 包含有界 stderr classification、exit status、runtime instance 和 protocol phase,但不能包含 raw secret、 +prompt、完整 provider payload 或 terminal output。Recoverability 是 Task policy 根据 error class 作出的决定。 +Bridge timeout 或 transport loss 不能宣称 OS effect 未发生。 + +## 迁移与兼容 + +1. 在 Phase 0 固化 private JSONL fixture 与 neutral Runtime contract。 +2. 在 `cosh-gateway` 下实现 `RuntimeSupervisor` 和 fake line-protocol child。 +3. 实现 `CoshCoreBridge` codec、negotiation、event normalization、correlation 与 session binding,但先不执行 + Broker operation。 +4. 增加只含 non-effecting/delegated tool 的 brokered launch profile,并集成 Broker。 +5. 连接 Task Run lease、cancel、durable approval/input、replay cursor 与 projection。 +6. 保留当前 direct Shell/Core adapter 作为 legacy;Shell 后续通过 Gateway wire client/mirror 与 canonical + fixture attach,不能增加 crate dependency。 +7. 将 Phase 2 ACP bridge 作为 sibling Runtime adapter,不能作为 CoshCoreBridge 内部 mode。 + +Rollback 禁用 Gateway runtime mode,并保留当前 cosh-core/Shell 行为与 provider session file。Private protocol +extension 需要显式 versioning 和协调 core/Shell/Gateway fixture change;不能静默重新解释 version 1。 + +## 依赖 + +- Phase 0 G0 schema/contracts、process supervision、provider trust、secret 与 storage 决策。 +- [Task Execution Plane](../task-execution-plane/design_zh.md):Run lease、runtime binding、durable event、 + input、approval、cancel 与 terminal state。 +- [Capability Broker](../capability-broker/design_zh.md):所有 side-effect tool decision 与 target execution。 +- [Gateway API](../gateway-api/design_zh.md):user-facing command 只能经 Task Coordinator。 +- 现有 cosh-core protocol/session code 与 cosh-shell fixture 只是 implementation evidence,不共享 domain + ownership。 + +## 实现任务分解 + +1. Inventory/freeze 所有 private JSONL input、output、limit 与 canonical fixture。 +2. 在 schema-first contracts 中定义 neutral Runtime command/event/binding。 +3. 实现可复用 `RuntimeSupervisor` process-group、I/O、deadline、kill/reap 和 restart lifecycle。 +4. 实现严格 bounded JSONL codec、v1 negotiation、correlation table 与 tombstone。 +5. 将 system/stream/assistant/tool/question/auth/evidence/result message 映射为 Runtime event。 +6. 实现 provider session binding/resume validation,但不能写 Task store。 +7. 实现 brokered core profile 和 `CapabilityBrokerPort`/target result flow。 +8. 集成 Task lease/cancel/backpressure 并增加 migration-compatible Shell mirror fixture。 +9. 增加 protocol drift、crash、malformed stream、race 与 security bypass test。 + +当前进度:第一轮实现已完成任务 3 的 process state/launch/process-group/bounded I/O/terminal 基础,以及 +任务 4 的 strict initialization 与 typed wire-observation 基础。任务 1-2 仍属于独立 contract/corpus 工作; +任务 5-9 尚未完成。 + +## 测试策略 + +- 每个 JSONL type 与 capability 组合的 canonical cross-implementation fixture。 +- 严格 negotiation test 覆盖 explicit v1、production 拒绝 legacy missing version、mismatch、错误 request ID、 + duplicate initialize、允许的 auth bootstrap 与其他 output-before-ready。 +- Parser fuzz 覆盖 oversized line、nesting、invalid UTF-8、partial JSON、unknown tag 与 EOF。 +- Mapping golden test 覆盖 status、chunk、tool call/result、approval、question、auth、evidence、environment + delta 和每个 terminal result/error。 +- Process test 覆盖 spawn failure、process-group descendant、stderr bound、broken pipe、cancel/result/EOF race、 + shutdown escalation、reap 与 restart budget。 +- Session test 覆盖 workspace mismatch、stale Run commit、validated resume、corrupt provider session 与 retry + without prompt replay。 +- Broker bypass test 证明 brokered mode 没有 side-effecting exposed tool 能收到 generic allow 或 core-local + execution。 +- Backpressure/crash test 证明 control 与 terminal event 不会静默丢失。 + +## 开放问题 + +| 问题 | Owner | Phase 1 默认值 | +| --- | --- | --- | +| 是否跨 Task 复用一个 persistent core? | Runtime owner | 单 active turn;clean settlement 且 profile/workspace validation 后才复用。 | +| Brokered profile 暴露哪些 core tool? | Core/Broker owner | 只暴露 audited non-effecting 或 host-delegated tool。 | +| Brokered profile 是否要求 private protocol v2? | Core/Bridge owner | Safe launch profile 可满足则避免;wire 变化时显式 version。 | +| 如何提供 credential? | Secret/security owner | Opaque credential reference;Task/event 不存 secret value。 | +| 最大 durable event lag 是多少? | Runtime/Task owner | Benchmark bounded queue;在 control-event loss 前安全 cancel。 | +| Failed turn 能否 resume provider session? | Runtime/product owner | 只有 validated session 且明确无 uncertain effect 时允许。 | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance.md new file mode 100644 index 0000000000..04885d6bfb --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance.md @@ -0,0 +1,99 @@ +# Phase 1 Gateway API Acceptance Baseline + +[中文版](acceptance_zh.md) | [Design](design.md) + +## Baseline result + +**Overall: NOT IMPLEMENTED at `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`.** Existing JSON +envelopes and correlated control messages are useful inputs, but no Gateway API, Task command +port, ingress identity, durable idempotency, or projection delivery path exists. + +This report records readiness before implementation. It must not be interpreted as a Phase 1 +acceptance pass. + +## Result vocabulary + +| Result | Meaning | +| --- | --- | +| PASS | Evidence at the pinned commit satisfies the criterion. | +| FAIL | An implementation exists but contradicts the criterion. | +| NOT IMPLEMENTED | The required production path does not exist. | +| BLOCKED | Verification cannot proceed until an identified external decision or dependency lands. | + +## Evidence inspected + +- Baseline: `git rev-parse HEAD` returned + `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. +- [`cosh-types/output.rs`](../../../../../crates/cosh-types/src/output.rs) defines the current CLI + response envelope. +- [`cosh-cli/main.rs`](../../../../../crates/cosh-cli/src/main.rs) dispatches directly to current + command modules. +- [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) defines an internal + shell/core JSONL protocol. +- [`cosh-core/session_control.rs`](../../../../../crates/cosh-core/src/session_control.rs) manages + provider sessions, not Tasks. +- Repository search found no `GatewayApi`, `IngressPort`, or `TaskCommandPort` implementation. + +## Acceptance matrix + +| ID | Criterion | Baseline | Evidence or missing artifact | +| --- | --- | --- | --- | +| GWA-001 | A versioned bounded local API accepts typed Task commands. | NOT IMPLEMENTED | No daemon/API module. | +| GWA-002 | Transport identity overrides any untrusted actor body. | NOT IMPLEMENTED | No identity resolver or ingress envelope. | +| GWA-003 | Handler code has no OS, PTY, process-spawn, Agent, or store capability. | NOT IMPLEMENTED | No handler boundary to inspect. | +| GWA-004 | Every mutation is sent through `TaskCommandPort`. | NOT IMPLEMENTED | Port absent. | +| GWA-005 | `TaskCoordinator` is the only Task aggregate writer. | NOT IMPLEMENTED | Task aggregate absent. | +| GWA-006 | Same request and digest replay the original receipt. | NOT IMPLEMENTED | No durable idempotency table. | +| GWA-007 | Same request with a different digest fails deterministically. | NOT IMPLEMENTED | No request ledger. | +| GWA-008 | Task reads and bounded event pages are tenant-authorized. | NOT IMPLEMENTED | No projection/event API. | +| GWA-009 | Approval resolution cannot create or widen a permit. | NOT IMPLEMENTED | Approval endpoint and Broker absent. | +| GWA-010 | Outbox delivery tolerates duplicate send and restart. | NOT IMPLEMENTED | No outbox consumer. | +| GWA-011 | Existing shell/core JSONL is not exposed as Gateway API. | PASS | It remains scoped to runtime code. | +| GWA-012 | Existing CLI behavior remains available when daemon is disabled. | PASS | No daemon integration exists yet. | +| GWA-013 | Remote listeners are disabled in Phase 1. | PASS | No listener exists; retain this property. | +| GWA-014 | Cross-channel identity authority is selected. | BLOCKED | Product/security owner decision remains open. | + +## Required fixtures and commands for implementation acceptance + +The implementation report must retain these artifacts under the eventual Gateway test owner: + +| Fixture/artifact | Purpose | +| --- | --- | +| `gateway-v1/*.json` golden corpus | Valid, invalid, oversized, unknown-version requests and responses. | +| `idempotency-replay` crash fixture | Commit a command, drop response, retry, compare receipt. | +| `forged-actor` fixture | Prove body identity cannot override peer/channel identity. | +| `handler-boundary` dependency test | Fail on imports of execution, PTY, process, store, or Agent bridge. | +| `outbox-redelivery` fixture | Restart between send and acknowledgment and prove stable Delivery ID. | + +Expected scoped commands after code exists are: + +```bash +cargo test --package cosh-gateway gateway_api +cargo test --package cosh-gateway gateway_contract +cargo test --package cosh-gateway-contracts gateway_schema +``` + +These commands were **not run** because the candidate package has no Gateway API implementation +or matching test targets. The existing package-level suite validates other candidate slices only; +documentation checks validate this still-unimplemented module's links and bilingual parity. + +## Exit criteria + +Phase 1 Gateway API is accepted only when: + +1. GWA-001 through GWA-013 are PASS; GWA-014 has a recorded decision or a deliberately local-only + scope with owner approval. +2. The handler-boundary test proves a Gateway handler cannot execute OS work. +3. Crash/retry fixtures demonstrate durable idempotency and transactional outbox behavior. +4. Security review covers peer credentials, tenant/actor binding, target substitution, replay, + resource limits, redaction, and approval authorization. +5. The acceptance report records the exact commit, commands, test counts, artifacts, and untested + external-channel paths. + +## Current risks + +- Reusing `CoshResponse` directly could conflate CLI execution with asynchronous Task receipt. +- Reusing the shell/core JSONL contract would leak runtime assumptions into public ingress. +- Adding channel handlers before Task idempotency would make weak-network retries unsafe. +- Treating a local single-user deployment as identity-free would make later remote migration a + breaking security change. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance_zh.md new file mode 100644 index 0000000000..bfae2745f4 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/acceptance_zh.md @@ -0,0 +1,93 @@ +# Phase 1 Gateway API 验收基线 + +[English](acceptance.md) | [设计](design_zh.md) + +## 基线结果 + +**整体结果:`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 上为 NOT IMPLEMENTED。** 现有 JSON +envelope 和相关联的 control message 可以复用,但不存在 Gateway API、Task command port、ingress +identity、持久幂等或 projection delivery path。 + +本文记录实现前 readiness,不能解读为 Phase 1 已验收通过。 + +## 结果口径 + +| 结果 | 含义 | +| --- | --- | +| PASS | 固定提交上的证据满足该验收项。 | +| FAIL | 已有实现,但行为违反该验收项。 | +| NOT IMPLEMENTED | 所需 production path 不存在。 | +| BLOCKED | 在指定外部决策或依赖完成前无法继续验证。 | + +## 已检查证据 + +- 基线:`git rev-parse HEAD` 返回 + `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。 +- [`cosh-types/output.rs`](../../../../../crates/cosh-types/src/output.rs) 定义当前 CLI response + envelope。 +- [`cosh-cli/main.rs`](../../../../../crates/cosh-cli/src/main.rs) 直接 dispatch 当前 command module。 +- [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) 定义内部 Shell/Core + JSONL protocol。 +- [`cosh-core/session_control.rs`](../../../../../crates/cosh-core/src/session_control.rs) 管理 + provider session,而不是 Task。 +- 仓库搜索没有发现 `GatewayApi`、`IngressPort` 或 `TaskCommandPort` 实现。 + +## 验收矩阵 + +| ID | 验收项 | 基线 | 证据或缺失产物 | +| --- | --- | --- | --- | +| GWA-001 | 带版本、有长度上限的本地 API 接收 typed Task command。 | NOT IMPLEMENTED | 无 daemon/API module。 | +| GWA-002 | Transport identity 覆盖不可信 actor body。 | NOT IMPLEMENTED | 无 identity resolver 或 ingress envelope。 | +| GWA-003 | Handler code 不具备 OS、PTY、process spawn、Agent 或 store 能力。 | NOT IMPLEMENTED | 无可检查的 handler boundary。 | +| GWA-004 | 所有 mutation 均通过 `TaskCommandPort`。 | NOT IMPLEMENTED | Port 不存在。 | +| GWA-005 | `TaskCoordinator` 是 Task aggregate 唯一 writer。 | NOT IMPLEMENTED | Task aggregate 不存在。 | +| GWA-006 | 同 request、同 digest 重放返回原 receipt。 | NOT IMPLEMENTED | 无持久 idempotency table。 | +| GWA-007 | 同 request、不同 digest 确定性失败。 | NOT IMPLEMENTED | 无 request ledger。 | +| GWA-008 | Task read 和有界 event page 均执行 tenant authorization。 | NOT IMPLEMENTED | 无 projection/event API。 | +| GWA-009 | Approval resolution 不能创建或扩大 permit。 | NOT IMPLEMENTED | Approval endpoint 与 Broker 不存在。 | +| GWA-010 | Outbox delivery 容忍重复发送与重启。 | NOT IMPLEMENTED | 无 outbox consumer。 | +| GWA-011 | 现有 Shell/Core JSONL 不作为 Gateway API 暴露。 | PASS | 它仍只位于 runtime code。 | +| GWA-012 | Daemon 禁用时现有 CLI 行为保持可用。 | PASS | 尚无 daemon integration。 | +| GWA-013 | Phase 1 禁止 remote listener。 | PASS | Listener 不存在;实现后必须保持此属性。 | +| GWA-014 | 已选择跨渠道 identity authority。 | BLOCKED | Product/security owner 决策未完成。 | + +## 实现验收要求的 fixture 与命令 + +实现报告必须在未来 Gateway test owner 下保留以下产物: + +| Fixture/产物 | 目的 | +| --- | --- | +| `gateway-v1/*.json` golden corpus | 覆盖合法、非法、超限、未知版本请求与响应。 | +| `idempotency-replay` crash fixture | Commit command 后丢弃 response,再 retry 并比较 receipt。 | +| `forged-actor` fixture | 证明 body identity 不能覆盖 peer/channel identity。 | +| `handler-boundary` dependency test | Import execution、PTY、process、store 或 Agent bridge 时失败。 | +| `outbox-redelivery` fixture | 在 send 与 ack 之间重启,证明 Delivery ID 稳定。 | + +代码存在后预期执行以下 scoped command: + +```bash +cargo test --package cosh-gateway gateway_api +cargo test --package cosh-gateway gateway_contract +cargo test --package cosh-gateway-contracts gateway_schema +``` + +本次**没有运行**这些命令,因为候选 package 尚无 Gateway API 实现或对应 test target。现有 package +suite 只验证其他候选切片;本模块仍未实现,文档检查只验证其链接与双语等价性。 + +## Exit criteria + +Phase 1 Gateway API 只有满足以下条件才算通过: + +1. GWA-001 至 GWA-013 全部 PASS;GWA-014 有正式决策,或由 owner 批准明确 local-only scope。 +2. Handler-boundary test 证明 Gateway handler 不能执行 OS 工作。 +3. Crash/retry fixture 证明持久幂等和 transactional outbox 行为。 +4. Security review 覆盖 peer credential、tenant/actor binding、target substitution、replay、resource + limit、redaction 与 approval authorization。 +5. 验收报告记录 exact commit、command、test count、artifact 与未测试的 external-channel path。 + +## 当前风险 + +- 直接复用 `CoshResponse` 可能混淆 CLI execution 与 asynchronous Task receipt。 +- 复用 Shell/Core JSONL contract 会把 runtime assumption 泄漏到 public ingress。 +- 在 Task idempotency 前增加 channel handler,会使弱网 retry 不安全。 +- 把 local single-user deployment 当作无 identity 环境,会令后续 remote migration 产生安全破坏性变更。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design.md new file mode 100644 index 0000000000..5c7b98158e --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design.md @@ -0,0 +1,295 @@ +# Phase 1 Gateway API Design + +[中文版](design_zh.md) | [Acceptance baseline](acceptance.md) + +## Status and scope + +This is a Phase 1 planning contract, not an implementation claim. It is based on upstream commit +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. The Gateway API is the local control-plane ingress +for Shell, Web, CLI, and future enterprise channel adapters. A handler authenticates and +normalizes intent, submits a command to the Task Execution Plane, and returns a task projection. +It never invokes an OS operation, starts an Agent process, or decides an approval itself. + +## Goals + +- Define one versioned, transport-neutral ingress contract for every client surface. +- Preserve actor, tenant, conversation, request, target, and trace identity across adapters. +- Make retries safe through durable idempotency and cursor-based event delivery. +- Keep request handlers stateless and keep `TaskCoordinator` as the only Task aggregate writer. +- Expose approval decisions without allowing a channel adapter to bypass policy. +- Support a Unix-domain transport first while leaving HTTP/WebSocket adapters possible later. + +## Non-goals + +- Public Internet exposure, DingTalk/Feishu implementation, or cross-device authentication. +- Agent protocol translation; that belongs to `CoshCoreBridge` in Phase 1 and `AcpClientBridge` in + Phase 2. +- OS execution, policy evaluation, permit issuance, task scheduling, or event storage. +- Reusing a channel message ID as a Task, Run, Agent session, or Shell session ID. +- Claiming exactly-once delivery across a client and the daemon. + +## Current-source evidence + +| Evidence at `6c115aef` | Reusable fact | Gap owned by this module | +| --- | --- | --- | +| [`cosh-cli/main.rs`](../../../../../crates/cosh-cli/src/main.rs) | CLI commands use typed subcommands and a JSON `CoshResponse` envelope. | There is no task-oriented daemon API or ingress identity. | +| [`cosh-types/output.rs`](../../../../../crates/cosh-types/src/output.rs) | Success and error responses are structurally separated and include metadata. | The envelope has no API version, request ID, Task ID, or event cursor. | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | The shell/core JSONL stream has correlated control requests. | It is an internal runtime protocol and must not become the Gateway API. | +| [`cosh-core/session_control.rs`](../../../../../crates/cosh-core/src/session_control.rs) | A bounded, provider-free one-request JSON management path exists. | It manages provider sessions only, not durable Tasks or approvals. | +| [`cosh-shell`](../../../../../crates/cosh-shell/src) | Shell owns rich interaction and approval rendering. | Shell is standalone and no reusable multi-channel ingress port exists. | + +The baseline contains no `GatewayApi`, `IngressPort`, channel adapter, Task endpoint, or durable +request-deduplication store. + +## Boundary and ownership + +```mermaid +flowchart LR + C["Shell / Web / CLI / channel"] --> A["ChannelAdapter"] + A --> I["IngressPort"] + I --> H["Gateway handler"] + H --> R["IdentityResolver"] + H --> T["TaskCommandPort"] + T --> Q["TaskCoordinator\nonly aggregate writer"] + Q --> P["TaskProjectionPort"] + P --> H + H --> O["PresentationPort"] + O --> C +``` + +The planned `cosh-gateway` process owns the local Gateway API, identity resolution facade, and +transport adapters. `TaskCommandPort` and `TaskProjectionPort` are its only paths into Task state. +Handlers cannot hold a `TaskStore`, `ExecutionTargetPort`, `CapabilityBroker`, or process-spawn +handle. This is enforced by module visibility and constructor dependencies, not a convention. + +Initial crate direction remains: + +```text +cosh-gateway -> cosh-platform -> cosh-types +cosh-gateway -> cosh-gateway-contracts +cosh-core -> cosh-platform -> cosh-types +cosh-cli -> cosh-platform -> cosh-types +cosh-shell remains standalone +``` + +`cosh-gateway` delegates provider child ownership to `RuntimeSupervisor`; it must not add a Rust +dependency from `cosh-core` back to the daemon. Neutral IDs and wire DTOs belong in the planned +side-effect-free leaf `cosh-gateway-contracts`, subject to the Phase 0 G0 schema-first ADR and final +crate naming. They do not enter the existing `cosh-types` by default. Transport and orchestration +types remain in `cosh-gateway` unless another crate needs them. + +## Ports + +```rust +trait IngressPort { + async fn submit(&self, envelope: IngressEnvelope) -> Result; +} + +trait IdentityResolver { + async fn resolve(&self, subject: ChannelSubject) -> Result; +} + +trait TaskCommandPort { + async fn dispatch(&self, command: TaskCommand) -> Result; +} + +trait TaskProjectionPort { + async fn get(&self, query: TaskQuery) -> Result; +} + +trait PresentationPort { + async fn publish(&self, delivery: Delivery) -> Result<(), DeliveryError>; +} +``` + +These are design signatures. Naming and async-trait mechanics are implementation decisions. + +## Typed schema + +Every request carries these independent values: + +```text +ApiVersion = "cosh.gateway.v1" +RequestId = caller-generated idempotency key within ActorScope +ChannelMessageId = provider delivery identity, optional +ConversationRef = provider thread/chat identity, optional +ActorId = authenticated COSH principal +TenantId = authorization boundary, even for a single-user local tenant +TaskId = durable user intent +RunId = one execution attempt +TargetRef = requested execution target, resolved later to TargetIdentity +TraceId = observability correlation only +``` + +The normalized envelope is: + +```json +{ + "api_version": "cosh.gateway.v1", + "request_id": "req_...", + "trace_id": "tr_...", + "source": { + "channel": "shell", + "channel_message_id": "msg_...", + "conversation_ref": "conv_..." + }, + "actor": {"tenant_id": "local", "actor_id": "usr_..."}, + "command": { + "type": "task.create", + "prompt": "inspect the failed service", + "target_ref": "local" + } +} +``` + +The server ignores an actor supplied in an untrusted body. The adapter passes channel credentials +to `IdentityResolver`, and the trusted result replaces body identity before dispatch. + +### Command surface + +| Command | Required fields | Semantics | +| --- | --- | --- | +| `task.create` | prompt, target reference | Creates a Task and its first queued Run. | +| `task.message.append` | Task ID, text | Adds user intent to an existing non-terminal Task. | +| `task.cancel` | Task ID, reason | Requests cancellation; does not kill a process in the handler. | +| `approval.resolve` | Task ID, approval ID, decision | Records an actor decision through `TaskCoordinator`. | +| `task.retry` | Task ID, failed Run ID | Requests a new fenced Run; never reopens an old Run. | +| `task.get` | Task ID | Reads a projection only. | +| `task.events.read` | Task ID, cursor, limit | Reads a bounded, ordered event page. | + +For a Unix-domain JSON transport, a command maps to one length-bounded request and response. A +future HTTP adapter may map create/append/cancel/approval to `POST`, reads to `GET`, and events to +SSE or WebSocket. The domain envelope and error codes do not depend on that mapping. + +## Handler pipeline + +1. Enforce byte, field-count, string, attachment-metadata, and deadline limits before parsing + unbounded content. +2. Validate `api_version` and reject unknown required fields for mutating commands. +3. Authenticate the channel transport and resolve immutable `ActorContext`. +4. Normalize channel-specific text, references, locale, and reply routing into the typed envelope. +5. Authorize the actor to address the tenant, Task, conversation binding, and target reference. +6. Dispatch one `TaskCommand` with `RequestId`, deadline, and expected Task version when supplied. +7. Return the durable command receipt and latest projection. Never wait for Agent or OS completion. +8. Publish asynchronous projections only through transactional outbox consumption. + +## State, transaction, idempotency, lease, and outbox semantics + +The Gateway owns no Task transaction. For mutating commands it supplies `RequestId` and waits for +the coordinator to atomically store the idempotency result with the Task event. A retry with the +same `(TenantId, ActorId, RequestId)` and the same canonical command digest returns the original +receipt. A different digest returns `idempotency_conflict`. + +Gateway handlers have no worker lease. A daemon shutdown may lose an in-flight socket response, +but the client can retry the same request. Task Run leases and fencing tokens are issued and +checked by the Task Execution Plane. + +Outbound channel delivery reads outbox rows written in the same transaction as Task events. A +delivery worker may send more than once, so `DeliveryId` is stable and adapters deduplicate where +the channel supports it. An outbox row advances only after acknowledgment; exponential retry, +dead-letter status, and cursor replay never mutate the Task aggregate directly. + +Event cursors are opaque, monotonic within a Task stream, and tenant-bound. Clients must tolerate +replayed events and must resync the projection after `cursor_expired`. + +## Security and approval rules + +- The local Unix socket uses restrictive ownership and peer credentials; bearer tokens are not a + substitute for filesystem permissions. +- Remote transports are disabled in Phase 1. Enabling one requires a separate threat model, TLS, + credential rotation, replay protection, and rate limits. +- Actor identity, target selection, and conversation binding are authorized independently. +- `approval.resolve` accepts only a live approval addressed to the actor or delegated role. It + cannot manufacture or widen a permit. +- Prompt and result text are untrusted content. They never select a module, executable, path, or + policy rule by string interpolation. +- Secrets, raw provider credentials, command output, and approval payloads are redacted before + logs or channel delivery. +- A Gateway handler never calls `cosh-platform`, `cosh-cli`, `Command::new`, a PTY, or an Agent + bridge. A dependency/lint test should fail if those symbols enter the handler module. + +## Error contract + +```json +{ + "ok": false, + "error": { + "code": "task_version_conflict", + "message": "task changed before this command was committed", + "recoverable": true, + "retry_after_ms": 50, + "details": {"task_id": "tsk_..."} + }, + "meta": { + "api_version": "cosh.gateway.v1", + "request_id": "req_...", + "trace_id": "tr_..." + } +} +``` + +Stable categories are `invalid_request`, `unsupported_version`, `unauthenticated`, `forbidden`, +`not_found`, `idempotency_conflict`, `task_version_conflict`, `rate_limited`, `deadline_exceeded`, +`store_unavailable`, and `internal`. Messages remain bounded and contain no secret detail. +Transport errors do not imply that a mutating command failed to commit. + +## Migration and compatibility + +1. Add pure Gateway IDs, envelopes, and stable errors under the schema-first contracts decision + without changing `CoshResponse`. +2. Add `cosh-gateway` with a local Unix socket and an in-process adapter for tests. +3. Let a development `cosh-cli task ...` adapter use the same `IngressPort`; existing pkg/svc/ + checkpoint/audit commands remain unchanged. +4. Add Shell as a client after the Task path is proven. The standalone Shell dependency boundary + remains intact by using a process or socket protocol. +5. Add Web and enterprise channels in Phase 2 or later. Old clients negotiate an API version; + no runtime JSONL message is silently treated as a Gateway request. + +Rollback disables the daemon and adapters; existing `cosh-cli`, `cosh-core`, and `cosh-shell` +entry points keep their current behavior. Database migration rollback is defined by the Task +Execution Plane, not a Gateway handler. + +## Dependencies + +- [Task Execution Plane](../task-execution-plane/design.md): commands, projections, idempotency, + event cursors, and outbox. +- [Capability Broker](../capability-broker/design.md): approval meaning and target authorization. +- [Cosh Core Bridge](../cosh-core-bridge/design.md): Agent runtime events, never called directly + by a handler. +- Phase 0 identity, schema, threat-model, and ACP fixture decisions where applicable. + +## Implementation work breakdown + +1. Define ID newtypes, bounded DTOs, version negotiation, and stable error codes. +2. Define `IngressPort`, `IdentityResolver`, `TaskCommandPort`, `TaskProjectionPort`, and contract + fakes. +3. Implement the Unix-domain adapter with peer-credential authentication and resource budgets. +4. Implement command normalization and authorization without OS/runtime dependencies. +5. Implement task query/event endpoints and opaque cursor validation. +6. Implement outbox presentation worker and adapter delivery deduplication. +7. Add compatibility adapters for a task CLI, then Shell; defer external channels. +8. Add dependency-boundary, fuzz, replay, and crash-recovery tests. + +## Test strategy + +- Schema golden tests for every command, response, unknown version, unknown field, and limit. +- Property tests proving independent IDs never deserialize into another ID type. +- Contract tests proving same request/same digest replays and same request/different digest fails. +- Authorization tests for cross-tenant Task IDs, target substitution, stale approvals, and forged + actor bodies. +- Dependency-boundary test proving handlers cannot import execution or process APIs. +- Crash test between coordinator commit and socket response, followed by idempotent retry. +- Outbox tests for duplicate delivery, reordered acknowledgment, cursor replay, and dead-lettering. +- Fuzzing for JSON framing, cursor parsing, Unicode bounds, and oversized nested values. + +No test may mutate the host. Any future pkg/svc fixture uses `--dry-run` or an isolated target. + +## Open questions + +| Question | Owner | Phase 1 default | +| --- | --- | --- | +| Which local transport framing is canonical? | Gateway owner | Bounded length-prefix over Unix socket; validate in spike. | +| Is the first persistence implementation single-user only? | Product/security | Keep `TenantId` mandatory even when its value is `local`. | +| Who is the source of channel-to-actor mappings? | Identity owner | Local config facade; external IdP deferred. | +| How long are event cursors retained? | Task storage owner | Policy-driven; return `cursor_expired` plus projection resync. | +| Are attachments accepted in Phase 1? | Gateway owner | Metadata references only; no arbitrary upload body. | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design_zh.md new file mode 100644 index 0000000000..82fdc2705b --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/gateway-api/design_zh.md @@ -0,0 +1,279 @@ +# Phase 1 Gateway API 设计 + +[English](design.md) | [验收基线](acceptance_zh.md) + +## 状态与范围 + +本文是 Phase 1 规划契约,不代表功能已经实现。源码基线为上游提交 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。Gateway API 是 Shell、Web、CLI 和未来企业 +渠道适配器的本地控制面入口。Handler 只负责认证、规范化用户意图、向 Task Execution Plane +提交命令并返回 Task projection,不执行 OS 操作,不启动 Agent 进程,也不自行作出审批决定。 + +## 目标 + +- 为所有客户端入口定义一个带版本、与传输无关的 ingress 契约。 +- 跨 adapter 保留 actor、tenant、conversation、request、target 和 trace 身份。 +- 通过持久幂等和基于 cursor 的事件投递,使重试安全。 +- 保持 handler 无状态,并使 `TaskCoordinator` 成为 Task aggregate 的唯一 writer。 +- 暴露审批决定入口,同时禁止渠道 adapter 绕过 policy。 +- 首先支持 Unix domain transport,并保留未来 HTTP/WebSocket adapter 的可能性。 + +## 非目标 + +- 公网暴露、钉钉/飞书实现或跨设备认证。 +- Agent 协议转换;Phase 1 由 `CoshCoreBridge` 负责,Phase 2 由 `AcpClientBridge` 负责。 +- OS 执行、policy 评估、permit 签发、Task 调度或事件存储。 +- 将 channel message ID 当作 Task、Run、Agent session 或 Shell session ID。 +- 声称客户端和 daemon 之间存在 exactly-once delivery。 + +## 当前源码证据 + +| `6c115aef` 的证据 | 可复用事实 | 本模块负责的缺口 | +| --- | --- | --- | +| [`cosh-cli/main.rs`](../../../../../crates/cosh-cli/src/main.rs) | CLI 使用 typed subcommand 和 JSON `CoshResponse` envelope。 | 没有面向 Task 的 daemon API 或 ingress identity。 | +| [`cosh-types/output.rs`](../../../../../crates/cosh-types/src/output.rs) | 成功和失败响应结构分离,并带 metadata。 | Envelope 没有 API version、request ID、Task ID 或 event cursor。 | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | Shell/Core JSONL stream 支持相关联的 control request。 | 它是内部 runtime protocol,不能成为 Gateway API。 | +| [`cosh-core/session_control.rs`](../../../../../crates/cosh-core/src/session_control.rs) | 已有带边界、无 provider 的单请求 JSON 管理路径。 | 只管理 provider session,不管理持久 Task 或审批。 | +| [`cosh-shell`](../../../../../crates/cosh-shell/src) | Shell 已拥有富交互和审批渲染。 | Shell 仍是 standalone,没有多渠道共享的 ingress port。 | + +基线不存在 `GatewayApi`、`IngressPort`、channel adapter、Task endpoint 或持久请求去重存储。 + +## 边界与 ownership + +```mermaid +flowchart LR + C["Shell / Web / CLI / channel"] --> A["ChannelAdapter"] + A --> I["IngressPort"] + I --> H["Gateway handler"] + H --> R["IdentityResolver"] + H --> T["TaskCommandPort"] + T --> Q["TaskCoordinator\n唯一 aggregate writer"] + Q --> P["TaskProjectionPort"] + P --> H + H --> O["PresentationPort"] + O --> C +``` + +规划中的 `cosh-gateway` 进程拥有本地 Gateway API、identity resolution facade 和 transport +adapter。`TaskCommandPort` 与 `TaskProjectionPort` 是访问 Task state 的唯一通道。Handler +不能持有 `TaskStore`、`ExecutionTargetPort`、`CapabilityBroker` 或进程 spawn handle。该约束通过 +module visibility 和 constructor dependency 强制执行,而不是依赖约定。 + +初始 crate 依赖方向保持如下: + +```text +cosh-gateway -> cosh-platform -> cosh-types +cosh-gateway -> cosh-gateway-contracts +cosh-core -> cosh-platform -> cosh-types +cosh-cli -> cosh-platform -> cosh-types +cosh-shell remains standalone +``` + +`cosh-gateway` 将 provider child ownership 交给 `RuntimeSupervisor`,但不能增加 `cosh-core` 反向依赖 +daemon 的 Rust dependency。中立 ID 与 wire DTO 归计划中的 side-effect-free leaf +`cosh-gateway-contracts` 所有,最终 crate 名称和 schema-first 落地受 Phase 0 G0 ADR 约束,默认不能 +直接并入现有 `cosh-types`。Transport 和 orchestration type 默认留在 `cosh-gateway`,除非另一个 crate +确实需要共享。 + +## Ports + +```rust +trait IngressPort { + async fn submit(&self, envelope: IngressEnvelope) -> Result; +} + +trait IdentityResolver { + async fn resolve(&self, subject: ChannelSubject) -> Result; +} + +trait TaskCommandPort { + async fn dispatch(&self, command: TaskCommand) -> Result; +} + +trait TaskProjectionPort { + async fn get(&self, query: TaskQuery) -> Result; +} + +trait PresentationPort { + async fn publish(&self, delivery: Delivery) -> Result<(), DeliveryError>; +} +``` + +以上仅为设计签名,命名和 async-trait 机制由实现阶段决定。 + +## Typed schema + +每个请求携带以下相互独立的值: + +```text +ApiVersion = "cosh.gateway.v1" +RequestId = caller 在 ActorScope 内生成的幂等键 +ChannelMessageId = provider delivery identity,可选 +ConversationRef = provider thread/chat identity,可选 +ActorId = 已认证的 COSH principal +TenantId = authorization boundary,即使单用户本地 tenant 也必须存在 +TaskId = 持久用户意图 +RunId = 一次执行尝试 +TargetRef = 请求的执行目标,稍后解析为 TargetIdentity +TraceId = 只用于 observability correlation +``` + +规范化 envelope 如下: + +```json +{ + "api_version": "cosh.gateway.v1", + "request_id": "req_...", + "trace_id": "tr_...", + "source": { + "channel": "shell", + "channel_message_id": "msg_...", + "conversation_ref": "conv_..." + }, + "actor": {"tenant_id": "local", "actor_id": "usr_..."}, + "command": { + "type": "task.create", + "prompt": "inspect the failed service", + "target_ref": "local" + } +} +``` + +Server 不信任请求 body 自带的 actor。Adapter 将渠道凭证交给 `IdentityResolver`,在 dispatch +之前以可信解析结果替换 body identity。 + +### 命令面 + +| 命令 | 必需字段 | 语义 | +| --- | --- | --- | +| `task.create` | prompt、target reference | 创建 Task 及其第一个 queued Run。 | +| `task.message.append` | Task ID、text | 向未进入 terminal state 的 Task 添加用户意图。 | +| `task.cancel` | Task ID、reason | 请求取消;handler 不直接 kill process。 | +| `approval.resolve` | Task ID、approval ID、decision | 通过 `TaskCoordinator` 记录 actor 决定。 | +| `task.retry` | Task ID、失败的 Run ID | 请求新的 fenced Run,不重新打开旧 Run。 | +| `task.get` | Task ID | 只读取 projection。 | +| `task.events.read` | Task ID、cursor、limit | 读取有界、有序事件页。 | + +Unix-domain JSON transport 中,一个命令映射为一个有长度上限的请求和响应。未来 HTTP adapter +可以把 create/append/cancel/approval 映射为 `POST`,read 映射为 `GET`,event 映射为 SSE 或 +WebSocket。Domain envelope 和错误码不依赖该映射。 + +## Handler pipeline + +1. 在解析无界内容前强制 byte、field count、string、attachment metadata 和 deadline 上限。 +2. 校验 `api_version`,mutating command 遇到未知 required field 时拒绝。 +3. 认证 channel transport 并解析不可变 `ActorContext`。 +4. 将渠道专属 text、reference、locale 和 reply routing 规范化成 typed envelope。 +5. 分别授权 actor 访问 tenant、Task、conversation binding 和 target reference。 +6. Dispatch 一个携带 `RequestId`、deadline 和可选 expected Task version 的 `TaskCommand`。 +7. 返回持久 command receipt 与最新 projection,不等待 Agent 或 OS 执行完成。 +8. 只通过 transactional outbox consumer 发布异步 projection。 + +## State、transaction、idempotency、lease 与 outbox 语义 + +Gateway 不拥有 Task transaction。Mutating command 携带 `RequestId`,由 coordinator 将幂等结果 +与 Task event 原子存储。同一个 `(TenantId, ActorId, RequestId)` 携带相同 canonical command +digest 重试时返回原 receipt;digest 不同则返回 `idempotency_conflict`。 + +Gateway handler 不持有 worker lease。Daemon shutdown 可能丢失正在返回的 socket response,但 +client 可以用相同 request 重试。Task Run lease 和 fencing token 由 Task Execution Plane 签发和校验。 + +Outbound channel delivery 读取与 Task event 在同一个 transaction 写入的 outbox row。Delivery worker +可能重复发送,因此 `DeliveryId` 保持稳定;channel 支持时由 adapter 去重。只有收到确认后才推进 +outbox row;指数退避、dead-letter 状态和 cursor replay 都不能直接修改 Task aggregate。 + +Event cursor 在单个 Task stream 内不透明且单调,并绑定 tenant。Client 必须容忍 event replay,并在 +`cursor_expired` 后重新同步 projection。 + +## 安全与审批规则 + +- 本地 Unix socket 使用严格 owner 权限和 peer credential;bearer token 不能代替文件系统权限。 +- Phase 1 禁用 remote transport。启用前必须单独完成 threat model、TLS、凭据轮转、防 replay 与 + rate limit。 +- Actor identity、target selection 和 conversation binding 分别授权。 +- `approval.resolve` 只接受仍有效且分配给该 actor 或 delegated role 的审批,不能制造或扩大 permit。 +- Prompt 和 result text 均为不可信内容,不能通过字符串插值选择 module、executable、path 或 policy rule。 +- Secret、原始 provider credential、command output 与审批 payload 在日志和渠道投递前完成脱敏。 +- Gateway handler 不调用 `cosh-platform`、`cosh-cli`、`Command::new`、PTY 或 Agent bridge。应通过 + dependency/lint test 阻止这些 symbol 进入 handler module。 + +## 错误契约 + +```json +{ + "ok": false, + "error": { + "code": "task_version_conflict", + "message": "task changed before this command was committed", + "recoverable": true, + "retry_after_ms": 50, + "details": {"task_id": "tsk_..."} + }, + "meta": { + "api_version": "cosh.gateway.v1", + "request_id": "req_...", + "trace_id": "tr_..." + } +} +``` + +稳定分类为 `invalid_request`、`unsupported_version`、`unauthenticated`、`forbidden`、 +`not_found`、`idempotency_conflict`、`task_version_conflict`、`rate_limited`、 +`deadline_exceeded`、`store_unavailable` 和 `internal`。Message 有长度上限且不包含 secret 细节。 +Transport error 不代表 mutating command 一定未提交。 + +## 迁移与兼容 + +1. 按 schema-first contracts 决策增加纯 Gateway ID、envelope 与稳定 error,不改变 + `CoshResponse`。 +2. 增加带本地 Unix socket 和测试用 in-process adapter 的 `cosh-gateway`。 +3. 让开发期 `cosh-cli task ...` adapter 使用同一个 `IngressPort`;现有 pkg/svc/checkpoint/audit + 命令保持不变。 +4. Task path 验证后再接入 Shell。Shell 通过进程或 socket protocol 保持 standalone 依赖边界。 +5. Phase 2 或以后增加 Web 与企业渠道。旧 client 协商 API version;不能把 runtime JSONL message + 静默当作 Gateway request。 + +Rollback 时禁用 daemon 和 adapter;现有 `cosh-cli`、`cosh-core` 与 `cosh-shell` 入口保持当前行为。 +Database migration rollback 由 Task Execution Plane 定义,不由 Gateway handler 定义。 + +## 依赖 + +- [Task Execution Plane](../task-execution-plane/design_zh.md):命令、projection、幂等、event cursor + 与 outbox。 +- [Capability Broker](../capability-broker/design_zh.md):审批含义与 target authorization。 +- [Cosh Core Bridge](../cosh-core-bridge/design_zh.md):Agent runtime event,handler 不直接调用。 +- Phase 0 的 identity、schema、threat-model 和适用的 ACP fixture 决策。 + +## 实现任务分解 + +1. 定义 ID newtype、有界 DTO、version negotiation 和稳定 error code。 +2. 定义 `IngressPort`、`IdentityResolver`、`TaskCommandPort`、`TaskProjectionPort` 和 contract fake。 +3. 实现带 peer-credential authentication 和 resource budget 的 Unix-domain adapter。 +4. 实现不依赖 OS/runtime 的 command normalization 与 authorization。 +5. 实现 Task query/event endpoint 和 opaque cursor 校验。 +6. 实现 outbox presentation worker 与 adapter delivery 去重。 +7. 先增加 task CLI compatibility adapter,再增加 Shell;external channel 延后。 +8. 增加 dependency-boundary、fuzz、replay 和 crash-recovery 测试。 + +## 测试策略 + +- 为每个命令、响应、未知版本、未知字段和上限建立 schema golden test。 +- Property test 证明相互独立的 ID 不能反序列化为另一种 ID type。 +- Contract test 证明同 request/同 digest 重放成功,同 request/不同 digest 失败。 +- 覆盖跨 tenant Task ID、target substitution、stale approval 和 forged actor body 的授权测试。 +- Dependency-boundary test 证明 handler 不能 import execution 或 process API。 +- 在 coordinator commit 与 socket response 之间 crash,随后执行幂等 retry。 +- 覆盖 duplicate delivery、ack 乱序、cursor replay 和 dead-letter 的 outbox test。 +- 对 JSON framing、cursor parsing、Unicode bound 和超大嵌套 value 进行 fuzz。 + +测试不能修改 host。未来 pkg/svc fixture 必须使用 `--dry-run` 或 isolated target。 + +## 开放问题 + +| 问题 | Owner | Phase 1 默认值 | +| --- | --- | --- | +| 哪种 local transport framing 为 canonical? | Gateway owner | Unix socket 上的有界 length-prefix;由 spike 验证。 | +| 首个 persistence implementation 是否只支持单用户? | Product/security | 即使值为 `local`,也强制保留 `TenantId`。 | +| 谁提供 channel-to-actor mapping? | Identity owner | Local config facade;external IdP 延后。 | +| Event cursor 保留多久? | Task storage owner | 由 policy 决定;过期返回 `cursor_expired` 并重同步 projection。 | +| Phase 1 是否接收 attachment? | Gateway owner | 只接 metadata reference,不接任意 upload body。 | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance.md new file mode 100644 index 0000000000..12fa8501c3 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance.md @@ -0,0 +1,117 @@ +# Phase 1 Task Execution Plane Acceptance Baseline + +[中文版](acceptance_zh.md) | [Design](design.md) + +## Baseline result + +**Overall: NOT IMPLEMENTED at `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`.** The repository +has robust provider-session persistence and audit evidence, but neither is a durable Task +aggregate. There is no coordinator, Task event store, Run lease, idempotency ledger, or outbox. + +This is a readiness report, not evidence that Phase 1 behavior passed. + +## First implementation result + +**Overall: VERIFIED FIRST SLICE; PHASE 1 EXIT NOT ACCEPTED.** The current working-tree candidate +adds shared Task IDs/events, a deterministic reducer, and an atomic SQLite Task store. It does not +yet add the sole-writer `TaskCoordinator`, Run lease/fencing, Outbox delivery workers, or execution +reconciliation. + +Evidence recorded on 2026-08-13: + +- `cargo test --locked --package cosh-gateway task::aggregate --no-fail-fast` passed 6/6 tests. +- `cargo test --locked --package cosh-gateway storage --no-fail-fast` passed 14/14 tests. +- `cargo clippy --locked --package cosh-gateway --lib -- -D warnings` passed. +- Tests cover revision gaps without mutation, explicit approval waiting, denial suspension, Run and + Task terminal closure, in-memory schema-version rejection, actor substitution, actor-scoped + idempotency replay/conflict, stale revisions, atomic Outbox rollback, schema/checksum rejection, + private-path attacks, causation persistence, and event replay after a durable reopen. + +## Result vocabulary + +| Result | Meaning | +| --- | --- | +| PASS | The pinned source and a reproducible artifact satisfy the criterion. | +| FAIL | A present implementation violates the criterion. | +| PARTIAL | A production slice exists, but named proof or behavior remains incomplete. | +| NOT IMPLEMENTED | No production path exists for the criterion. | +| BLOCKED | A named upstream decision or dependency prevents verification. | + +## Baseline evidence + +- `git rev-parse HEAD` identified + `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. +- [`session.rs`](../../../../../crates/cosh-core/src/session.rs) defines provider-session schema, + identity, generation, summary, and health. +- [`session/store.rs`](../../../../../crates/cosh-core/src/session/store.rs) atomically persists one + provider session with optimistic generation. +- [`runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) is Shell in-memory + presentation/runtime state. +- [`audit/event.rs`](../../../../../crates/cosh-types/src/audit/event.rs) is security evidence and + does not own Task transitions. +- Repository search found no `TaskCoordinator`, `TaskEventStore`, `TaskId`, or Task outbox. + +## Acceptance matrix + +| ID | Criterion | Baseline | Evidence or missing artifact | +| --- | --- | --- | --- | +| TEP-001 | Typed `TaskId`, `RunId`, and lifecycle schemas exist. | PASS | `cosh-gateway-contracts::{ids,task}`. | +| TEP-002 | Coordinator is the only aggregate writer. | NOT IMPLEMENTED | Coordinator absent. | +| TEP-003 | State reducer rejects every illegal transition. | PARTIAL | Reducer exists and critical transition tests pass; exhaustive state/event matrix is pending. | +| TEP-004 | Event, snapshot, idempotency receipt, and outbox commit atomically. | PASS | `commit_task` uses `BEGIN IMMEDIATE`; a duplicate Delivery ID proves complete rollback. | +| TEP-005 | Expected revision prevents stale writers. | PASS | Revision-conflict test leaves all Task tables empty. | +| TEP-006 | Run lease has monotonic fencing and bounded renewal. | NOT IMPLEMENTED | Run lease absent. | +| TEP-007 | Lease expiry never replays an unknown OS effect automatically. | NOT IMPLEMENTED | Reconciliation path absent. | +| TEP-008 | Approval resolution is first-valid-terminal-wins. | PARTIAL | Reducer rejects resolved/non-pending approval IDs, but authorization and concurrent-decision fixture are pending. | +| TEP-009 | Runtime and execution callbacks are idempotent and fenced. | NOT IMPLEMENTED | Ports absent. | +| TEP-010 | Event replay rebuilds an equivalent projection. | PASS | Durable-reopen recovery replays ordered envelopes and compares the exact snapshot. | +| TEP-011 | Outbox restart is at-least-once with stable Delivery IDs. | PARTIAL | Stable rows persist, but dispatch leasing/reclaim/ack does not exist. | +| TEP-012 | Task records exclude raw streams, secrets, and terminal buffers. | PARTIAL | Snapshot/event leaves are typed and bounded; Outbox payload, collection aggregate bounds, and secret classification remain. | +| TEP-013 | Corrupt/incompatible histories fail closed and remain inspectable. | PARTIAL | Schema/replay fails closed; quarantine and inspect surface are pending. | +| TEP-014 | Provider `SessionStore` remains separate from Task storage. | PASS | Gateway SQLite is a separate crate/store and schema. | +| TEP-015 | Final storage engine and durability profile are approved. | BLOCKED | Phase 0 storage ADR is pending. | + +## Required fixtures, commands, and artifacts + +| Artifact | Required proof | +| --- | --- | +| `task-events-v1` golden corpus | Stable codecs, required/optional compatibility, bounds. | +| Complete transition table | Every state/command pair has an expected result. | +| `task-store-vN` migration fixtures | Upgrade, backup, inspect, and incompatible-version behavior. | +| Kill-point matrix | Atomicity before/during/after commit and delivery acknowledgment. | +| `expired-lease-uncertain-effect` | New worker suspends instead of re-executing. | +| Concurrent approval fixture | Exactly one conflicting terminal decision wins. | +| Replay digest artifact | Live projection equals event-reduced projection. | + +Expected commands after implementation are: + +```bash +cargo test --package cosh-gateway task_model +cargo test --package cosh-gateway task_store +cargo test --package cosh-gateway task_crash_recovery +cargo test --package cosh-gateway-contracts task_schema +``` + +The implemented target names are broader than the original placeholders. The exact targeted +commands and counts are recorded above. Full workspace gates and live/ECS validation remain outside +this scope-proportional first slice. + +## Exit criteria + +1. TEP-001 through TEP-014 are PASS and the Phase 0 decision clears TEP-015. +2. Model, concurrent-writer, crash, corruption, migration, and reconciliation fixtures pass at the + exact candidate commit. +3. A code-ownership check proves adapters, handlers, bridges, workers, and presenters cannot write + Task storage outside `TaskCoordinator`. +4. Security review verifies tenant/workspace scope, actor/delegation, event redaction, lease fence, + approval races, uncertain execution, and store permissions. +5. The acceptance report lists the exact store engine/configuration, commands, test counts, + artifacts, unsupported migration paths, and rollback procedure. + +## Current risks + +- Extending provider `SessionStore` would conflate model conversation with control-plane truth. +- A file-per-Task design may not support atomic event/idempotency/outbox commit without an + additional transaction layer. +- Treating a process PID or lease timeout as completion can repeat side effects. +- Letting presenters or callbacks mutate approval state creates split-brain authorization. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance_zh.md new file mode 100644 index 0000000000..24c66ce845 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/acceptance_zh.md @@ -0,0 +1,114 @@ +# Phase 1 Task Execution Plane 验收基线 + +[English](acceptance.md) | [设计](design_zh.md) + +## 基线结果 + +**整体结果:`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 上为 NOT IMPLEMENTED。** 仓库已有 +可靠的 provider-session persistence 与 audit evidence,但二者都不是持久 Task aggregate。当前不存在 +coordinator、Task event store、Run lease、idempotency ledger 或 outbox。 + +本文是 readiness report,不是 Phase 1 行为已经通过的证据。 + +## 首个实现结果 + +**整体结果:首个切片已验证;Phase 1 Exit 尚未接受。** 当前工作树候选新增共享 Task ID/event、确定性 +reducer 与 atomic SQLite Task store。它还没有实现唯一 writer `TaskCoordinator`、Run lease/fencing、 +Outbox delivery worker 或 execution reconciliation。 + +2026-08-13 记录的证据: + +- `cargo test --locked --package cosh-gateway task::aggregate --no-fail-fast` 通过 6/6 tests。 +- `cargo test --locked --package cosh-gateway storage --no-fail-fast` 通过 14/14 tests。 +- `cargo clippy --locked --package cosh-gateway --lib -- -D warnings` 通过。 +- Test 覆盖 revision gap 错误不修改 aggregate、显式 approval waiting、deny 后 suspension、Run 与 Task + terminal closure、in-memory schema-version rejection、actor substitution、actor-scoped idempotency + replay/conflict、stale revision、Outbox atomic rollback、schema/checksum rejection、private-path attack、 + causation persistence,以及 durable reopen 后 event replay。 + +## 结果口径 + +| 结果 | 含义 | +| --- | --- | +| PASS | 固定源码和可复现产物满足该验收项。 | +| FAIL | 已存在的实现违反该验收项。 | +| PARTIAL | 已有 production 切片,但指定证据或行为仍不完整。 | +| NOT IMPLEMENTED | 该验收项没有 production path。 | +| BLOCKED | 指定上游决策或依赖阻止验证。 | + +## 基线证据 + +- `git rev-parse HEAD` 确认为 + `6c115aefe04ace0d169a24fa7cd55ad7c1befa52`。 +- [`session.rs`](../../../../../crates/cosh-core/src/session.rs) 定义 provider-session schema、identity、 + generation、summary 与 health。 +- [`session/store.rs`](../../../../../crates/cosh-core/src/session/store.rs) 使用 optimistic generation + 原子持久化一个 provider session。 +- [`runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) 属于 Shell in-memory + presentation/runtime state。 +- [`audit/event.rs`](../../../../../crates/cosh-types/src/audit/event.rs) 是 security evidence,不拥有 Task + transition。 +- 仓库搜索没有发现 `TaskCoordinator`、`TaskEventStore`、`TaskId` 或 Task outbox。 + +## 验收矩阵 + +| ID | 验收项 | 基线 | 证据或缺失产物 | +| --- | --- | --- | --- | +| TEP-001 | 存在 typed `TaskId`、`RunId` 和 lifecycle schema。 | PASS | `cosh-gateway-contracts::{ids,task}`。 | +| TEP-002 | Coordinator 是 aggregate 唯一 writer。 | NOT IMPLEMENTED | Coordinator 不存在。 | +| TEP-003 | State reducer 拒绝所有非法 transition。 | PARTIAL | Reducer 与关键 transition test 已通过;完整 state/event matrix 待补。 | +| TEP-004 | Event、snapshot、idempotency receipt 与 outbox 原子 commit。 | PASS | `commit_task` 使用 `BEGIN IMMEDIATE`;重复 Delivery ID 证明完整 rollback。 | +| TEP-005 | Expected revision 阻止 stale writer。 | PASS | Revision-conflict test 后所有 Task table 仍为空。 | +| TEP-006 | Run lease 使用 monotonic fencing 与有界 renewal。 | NOT IMPLEMENTED | Run lease 不存在。 | +| TEP-007 | Lease expiry 不会自动重放 unknown OS effect。 | NOT IMPLEMENTED | Reconciliation path 不存在。 | +| TEP-008 | Approval resolution 使用 first-valid-terminal-wins。 | PARTIAL | Reducer 拒绝已 resolve/非 pending ID;authorization 与 concurrent-decision fixture 待补。 | +| TEP-009 | Runtime 与 execution callback 幂等且带 fence。 | NOT IMPLEMENTED | Port 不存在。 | +| TEP-010 | Event replay 重建等价 projection。 | PASS | Durable reopen recovery replay ordered envelope,并比较完整 snapshot。 | +| TEP-011 | Outbox restart 使用 at-least-once 与稳定 Delivery ID。 | PARTIAL | 稳定 row 已持久化,但 dispatch lease/reclaim/ack 不存在。 | +| TEP-012 | Task record 排除 raw stream、secret 与 terminal buffer。 | PARTIAL | Snapshot/event leaf 已 typed/bounded;Outbox payload、collection aggregate bound 与 secret classification 待补。 | +| TEP-013 | Corrupt/incompatible history fail closed 且可 inspect。 | PARTIAL | Schema/replay 已 fail closed;quarantine 与 inspect surface 待补。 | +| TEP-014 | Provider `SessionStore` 与 Task storage 保持分离。 | PASS | Gateway SQLite 使用独立 crate/store 与 schema。 | +| TEP-015 | Final storage engine 与 durability profile 已批准。 | BLOCKED | Phase 0 storage ADR 未完成。 | + +## 要求的 fixture、命令与产物 + +| 产物 | 必须提供的证明 | +| --- | --- | +| `task-events-v1` golden corpus | 稳定 codec、required/optional compatibility 与 bounds。 | +| 完整 transition table | 每个 state/command 组合都有 expected result。 | +| `task-store-vN` migration fixture | Upgrade、backup、inspect 与 incompatible-version 行为。 | +| Kill-point matrix | Commit 和 delivery ack 前、中、后的 atomicity。 | +| `expired-lease-uncertain-effect` | 新 worker suspend,而不是重新 execute。 | +| Concurrent approval fixture | 冲突 terminal decision 只有一个获胜。 | +| Replay digest artifact | Live projection 与 event-reduced projection 相同。 | + +实现后预期运行: + +```bash +cargo test --package cosh-gateway task_model +cargo test --package cosh-gateway task_store +cargo test --package cosh-gateway task_crash_recovery +cargo test --package cosh-gateway-contracts task_schema +``` + +当前实现 target 名比最初 placeholder 更宽。上文已经记录 exact targeted command 与 count。完整 workspace +gate 与 live/ECS validation 不属于本次按范围验证的首个切片。 + +## Exit criteria + +1. TEP-001 至 TEP-014 全部 PASS,且 Phase 0 决策解除 TEP-015。 +2. Model、concurrent-writer、crash、corruption、migration 与 reconciliation fixture 在 exact candidate + commit 上通过。 +3. Code-ownership check 证明 adapter、handler、bridge、worker 和 presenter 不能绕过 `TaskCoordinator` + 写 Task storage。 +4. Security review 验证 tenant/workspace scope、actor/delegation、event redaction、lease fence、approval race、 + uncertain execution 与 store permission。 +5. 验收报告列出 exact store engine/configuration、command、test count、artifact、unsupported migration path + 与 rollback procedure。 + +## 当前风险 + +- 扩展 provider `SessionStore` 会混淆 model conversation 与 control-plane truth。 +- File-per-Task design 如果没有额外 transaction layer,可能无法原子 commit event/idempotency/outbox。 +- 把 process PID 或 lease timeout 当成 completion,可能重复 side effect。 +- 允许 presenter 或 callback 修改 approval state,会造成 split-brain authorization。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design.md new file mode 100644 index 0000000000..22142be705 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design.md @@ -0,0 +1,375 @@ +# Phase 1 Task Execution Plane Design + +[中文版](design_zh.md) | [Acceptance baseline](acceptance.md) + +## Status and decision + +This document plans Phase 1 against upstream commit +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. The current working-tree candidate now contains the +first Task reducer and SQLite persistence slice; it is not the complete Phase 1 service. The Task +Execution Plane makes user intent durable independently of a channel connection, Agent process, +provider session, PTY, or OS execution attempt. The planned `TaskCoordinator` remains the sole +writer of the Task aggregate. Every other module submits a typed command and observes committed +events or projections. + +The first deployment may host the coordinator, runner, projection worker, and stores in the +`cosh-gateway` process. Their ownership and port boundaries remain separate. + +### Implemented first slice + +- `task/aggregate.rs` is a deterministic, non-mutating-on-error reducer over the shared + `TaskEventEnvelope` contract. It enforces consecutive revisions, Task/correlation identity, + explicit `WaitingApproval`, terminal Task closure, and a terminal Run fact before Task closure. +- `storage/task_store.rs` owns the only exposed mutable SQLite connection through + `&mut SqliteTaskStore`. `BEGIN IMMEDIATE` atomically appends events, updates the snapshot, + records the actor-scoped idempotency receipt, and inserts stable Outbox rows. +- Exact replay returns the stored receipt before evaluating a now-stale revision. Reusing the + same actor/key with another digest fails, as does an optimistic-revision mismatch. +- Recovery decodes all versioned events, reruns the reducer, and fails closed when the rebuilt + projection differs from the stored snapshot. +- Run leases, a `TaskCoordinator` mailbox, Outbox dispatch/lease workers, approval authorization, + execution reconciliation, and corruption quarantine remain later slices. + +## Goals + +- Persist Tasks, Runs, inputs, approvals, runtime bindings, execution references, and terminal + outcomes with explicit versions. +- Resume safely after daemon, Agent, presentation, or weak-network interruption. +- Serialize every Task transition through one writer and reject stale commands. +- Atomically append Task events, update snapshots, store idempotency results, and enqueue outbox + delivery. +- Use renewable Run leases and fencing tokens without treating lease expiry as proof that an OS + side effect is safe to repeat. +- Keep Task events bounded and separate from security audit records and raw stream storage. + +## Non-goals + +- Replacing provider conversation persistence in `SessionStore`. +- Authorizing OS operations or issuing permits; those belong to `CapabilityBroker`. +- Owning a child process or translating cosh-core/ACP messages; runtime bridges do that. +- Storing raw model streams, terminal output, credentials, environment snapshots, or file bodies. +- Providing exactly-once OS side effects. Uncertain effects require reconciliation. +- Selecting the final embedded database before the Phase 0 storage ADR is accepted. + +## Current-source evidence + +| Evidence at `6c115aef` | Reusable behavior | Task-plane gap | +| --- | --- | --- | +| [`cosh-core/session.rs`](../../../../../crates/cosh-core/src/session.rs) | Workspace-scoped `ProviderSessionId`, schema version, generation, and typed health/errors. | A provider transcript is not a Task or Run. | +| [`cosh-core/session/store.rs`](../../../../../crates/cosh-core/src/session/store.rs) | Locking, optimistic generation, bounded files, atomic replacement, and scope validation. | File envelopes cannot atomically own Task event, idempotency, lease, and outbox rows. | +| [`cosh-core/session_control.rs`](../../../../../crates/cosh-core/src/session_control.rs) | Bounded list/inspect/validate/clear management protocol. | No Task command or durable execution lifecycle. | +| [`cosh-shell/runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) | In-memory inline runtime state for one interactive Shell. | State is process-local and presentation-owned. | +| [`cosh-shell/adapter/mod.rs`](../../../../../crates/cosh-shell/src/adapter/mod.rs) | Agent run handle and event callback pattern. | Lifecycle is Shell-owned and not durable. | +| [`cosh-types/audit/event.rs`](../../../../../crates/cosh-types/src/audit/event.rs) | Audit identity already has bounded correlation fields such as run/request/tool use. | Audit events are evidence, not aggregate state or delivery queues. | + +No `Task`, `TaskId`, durable `RunId`, `TaskCoordinator`, `TaskEventStore`, lease table, +idempotency ledger, Task projection, or outbox exists at the baseline. + +## Aggregate ownership and ports + +```mermaid +flowchart TB + G["Gateway TaskCommandPort"] --> C["TaskCoordinator\nsole Task writer"] + R["RuntimeEventPort"] --> C + B["BrokerResultPort"] --> C + A["ApprovalCommandPort"] --> C + C --> S["TaskStore transaction"] + S --> E[("Task events")] + S --> P[("Task snapshot")] + S --> I[("Idempotency ledger")] + S --> L[("Run leases")] + S --> O[("Outbox")] + O --> W["Projection / delivery workers"] + C --> AR["AgentRuntimePort"] + C --> CB["CapabilityBrokerPort"] +``` + +`TaskCoordinator` owns aggregate validation and event decisions. It does not own channel +transport, Agent wire parsing, policy evaluation, OS execution, or UI rendering. A per-Task actor +mailbox serializes commands within one process. The store also enforces `expected_revision` so a +second process or stale lease cannot bypass the invariant. + +Conceptual ports are: + +```rust +trait TaskCommandPort { + async fn execute(&self, command: TaskCommand) -> Result; +} + +trait TaskEventStore { + async fn load(&self, task_id: TaskId) -> Result; + async fn commit(&self, batch: TaskCommit) -> Result; +} + +trait TaskLeasePort { + async fn acquire(&self, run_id: RunId, owner: WorkerId) -> Result; + async fn renew(&self, lease: RunLease) -> Result; +} + +trait TaskProjectionPort { + async fn get(&self, task_id: TaskId) -> Result; + async fn events(&self, query: EventQuery) -> Result; +} +``` + +These signatures define responsibilities, not final Rust syntax. + +Neutral Task/Run IDs and cross-process command/event DTOs follow the Phase 0 G0 schema-first +decision and belong in the planned side-effect-free `cosh-gateway-contracts` leaf once its name and +crate boundary are accepted. Aggregate reducer, storage records, leases, and coordinator internals +remain private to `cosh-gateway`; they are not wire contracts and do not enter existing +`cosh-types`. + +## Identity and aggregate schema + +IDs are typed newtypes with canonical textual encodings. They cannot be assigned across types. + +| ID | Authority | Lifetime | +| --- | --- | --- | +| `TaskId` | Task Coordinator | Durable user intent. | +| `RunId` | Task Coordinator | One attempt under a Task. | +| Task event `MessageId` | Contract producer | One immutable aggregate event. | +| `AgentSessionId` | Runtime bridge | Runtime conversation binding, never Task identity. | +| `ApprovalId` | Task Coordinator | One durable gate. | +| `ExecutionId` | Capability Broker | One side-effect attempt. | +| `IdempotencyKey` | Command initiator | Command replay namespace in actor scope. | +| `DeliveryId` | Task transaction | One outbox intent. | + +The aggregate snapshot contains bounded control data: + +```text +Task { + task_id, tenant_id, actor_id, target_ref, + state, revision, created_at, updated_at, + active_run_id?, latest_input_ref?, + pending_approval_ids[], runtime_binding_ref?, + result_summary?, failure? +} + +Run { + run_id, attempt, state, runtime_profile, + started_at?, finished_at?, lease_fence, + agent_session_id?, last_runtime_cursor?, + execution_ids[], terminal_reason? +} +``` + +`latest_input_ref` references bounded/redacted content storage. Events contain hashes, sizes, and +opaque evidence references, not raw prompt, model thought, terminal buffer, or credentials. + +## State machine + +```mermaid +stateDiagram-v2 + [*] --> Submitted + Submitted --> Queued: admitted + Queued --> Running: valid lease acquired + Running --> WaitingApproval: gate committed + WaitingApproval --> Running: valid resolution committed + WaitingApproval --> Suspended: approval expired + Running --> WaitingInput: elicitation committed + WaitingInput --> Running: input appended + Running --> Suspended: runtime unavailable or uncertain effect + Suspended --> Queued: explicit retry or reconciled resume + Running --> Succeeded: result committed + Running --> Failed: terminal failure committed + Submitted --> Cancelled: cancel + Queued --> Cancelled: cancel + Running --> Cancelled: cancellation confirmed + WaitingApproval --> Cancelled: cancel + WaitingInput --> Cancelled: cancel +``` + +Terminal states are `Succeeded`, `Failed`, and `Cancelled`. They never reopen. `retry` creates a +new `RunId` while preserving `TaskId`. `Suspended` records a recoverable stop and the required +operator or policy action. A cancellation request while a runtime or execution is active records +`CancellationRequested`; `Cancelled` is committed only after the owning bridge/target confirms +settlement or a reviewed reconciliation policy declares it terminal. + +## Command and event schema + +Every command includes `tenant_id`, `actor_context`, `request_id`, `expected_revision` when known, +`issued_at`, and `deadline`. Core commands include: + +```text +CreateTask, AdmitTask, AcquireRun, RenewRunLease, +AppendInput, RequestApproval, ResolveApproval, +RecordRuntimeBinding, RecordRuntimeEvent, +RecordExecutionPlanned, RecordExecutionResult, +RequestCancellation, ConfirmCancellation, +SuspendRun, RetryRun, CompleteRun, FailRun +``` + +Only coordinator-internal principals may issue lease/runtime/execution commands. Gateway actors +may create, append, cancel, retry, and resolve an assigned approval. + +Representative immutable events are: + +```text +TaskSubmitted, TaskQueued, RunStarted, RunLeaseRenewed, +InputAppended, RuntimeBound, RuntimeEventRecorded, +ApprovalRequested, ApprovalResolved, ApprovalExpired, +ExecutionPlanned, ExecutionResultRecorded, ExecutionUncertain, +CancellationRequested, RunCancelled, RunSuspended, +RunSucceeded, RunFailed, RunRetryQueued, TaskSucceeded, TaskFailed, TaskCancelled +``` + +Each event has `schema`, `schema_version`, `task_id`, `event_id`, `sequence`, `task_revision`, +`occurred_at`, `causation_id`, `correlation_id`, actor/runtime principal, and a bounded typed +payload. Unknown optional fields are ignored within a schema generation; unknown required event +types stop replay and mark the Task incompatible. + +## Transaction and optimistic concurrency + +One accepted mutation performs this transaction: + +1. Read the Task row and latest revision under the store's write serialization. +2. Resolve `(tenant, principal, request_id)` from the idempotency ledger. +3. Reject a reused request with a different canonical command digest. +4. Validate `expected_revision`, state transition, lease fence, approval state, and referenced IDs. +5. Append one or more immutable events with consecutive sequence numbers. +6. Replace the projection/snapshot with `revision + 1` or the event batch's final revision. +7. Insert the command receipt in the idempotency ledger. +8. Insert all projection/delivery intents into the outbox. +9. Commit atomically, then publish in-memory notifications. + +If the transaction outcome is unknown to the caller, retrying the same `IdempotencyKey` returns the +stored receipt. A store conflict triggers reload and command re-evaluation; it never performs a +blind event append. + +The first storage slice provides atomic uniqueness and transactions with SQLite WAL, +`synchronous=FULL`, foreign keys, strict tables, and one owned write connection. The Phase 0 ADR +still requires backup/restore, checkpoint health, corruption quarantine, and operational runbooks +before final exit. Public contracts do not expose SQLite types. + +## Idempotency semantics + +- Scope is `(ActorId, IdempotencyKey)`; tenant/workspace authorization stays at ingress. +- The canonical digest includes command type, Task/Run references, normalized payload, and target + reference; it excludes trace IDs and deadlines. +- Successful and domain-error receipts are retained long enough to cover channel retry policy. +- An in-progress ledger row cannot be left without a transaction owner; no two-phase placeholder + exists outside the commit. +- Runtime events deduplicate by `(RuntimeInstanceId, source_sequence)` or a bridge-issued stable + event identity. +- Approval resolution is first-valid-terminal-wins. Later duplicates return the stored decision; + conflicting decisions return `approval_already_resolved`. +- Execution results deduplicate by `ExecutionId` and result revision. + +## Run lease and fencing semantics + +A `RunLease` contains `run_id`, `owner_id`, `fence`, `acquired_at`, `expires_at`, and renewal +deadline. `fence` increases on every acquisition. Every runtime command and coordinator callback +includes the fence; stale owners are rejected even if their process is still alive. + +Lease expiry allows another worker to reconcile and acquire orchestration ownership. It does not +authorize replay of an `ExecutionId`, resend of a prompt, or reuse of a permit. Before retry, the +new worker asks the runtime bridge and Broker for their durable/observable status. Unknown side +effects produce `ExecutionUncertain` and `Suspended`, not automatic retry. + +Renewal uses bounded jitter and stops before expiry. A worker that cannot renew stops admitting +new work and requests cancellation of owned runtime operations; it cannot write after its fence +is stale. + +## Outbox and projection semantics + +The Task transaction writes `DeliveryIntent` rows containing `delivery_id`, `task_id`, event range, +presentation kind, destination binding reference, redaction profile, attempt count, and next +attempt time. It never stores a channel credential or unbounded rendered body. + +Projection workers build channel-neutral views from events and bounded evidence. Delivery is +at-least-once. A stable `DeliveryId` and destination idempotency token suppress duplicates where +supported. A failed or dead-lettered delivery changes delivery projection only; it cannot fail or +rewind the Task. Event consumers store `(consumer_id, task_id, sequence)` checkpoints and tolerate +replay. + +## Approval, security, and audit + +- `ApprovalRequest` and its resolution are Task state; a card or callback is only presentation. +- Only the coordinator can commit an approval resolution. It validates actor/delegation, Task and + Run state, expiry, operation digest, target binding, and current policy revision. +- A committed approval is an input to the Broker. It is not itself an executable permit. +- Every side-effect event references `ExecutionId`; the corresponding security audit event carries + Task/Run/Execution correlation without becoming the Task source of truth. +- Task storage permissions are private to the daemon account. Tenant and workspace scope are + checked before opening or querying records. +- Stored text and failure details are bounded and redacted. Secret-bearing data uses an external + secret reference and never enters events or outbox. +- Corrupt, unsupported, or scope-mismatched histories fail closed and remain inspectable. + +## Error model + +Stable categories include `invalid_command`, `not_found`, `forbidden`, `version_conflict`, +`idempotency_conflict`, `invalid_transition`, `stale_lease`, `approval_expired`, +`approval_already_resolved`, `runtime_unavailable`, `execution_uncertain`, `store_busy`, +`store_corrupt`, `incompatible_schema`, and `internal`. + +Errors state whether the client may retry the same request, retry with a refreshed revision, +request reconciliation, or must stop. A timeout never reports that a command did not commit. +Store and serialization errors include bounded developer context but no prompt, terminal output, +credentials, or filesystem contents. + +## Migration and recovery + +1. Freeze Phase 0 ID/event/storage ADRs and add pure schema types. +2. Create an empty versioned Task store without importing provider `SessionStore` records. +3. Add coordinator replay, snapshots, command ledger, leases, and outbox behind in-memory fakes. +4. Add local persistent adapter and crash fixtures. +5. Connect Gateway commands, then `CoshCoreBridge`, then Broker callbacks. +6. Keep direct `cosh-shell` and existing CLI flows during opt-in migration. + +Provider sessions may be linked by `AgentSessionId` and opaque binding metadata; they are never +converted into Tasks automatically. Rollback disables Task ingress and preserves existing +provider session files. Store schema migration must use forward backups and an offline validator; +never silently downgrade a newer event generation. + +On startup the daemon validates schema and store integrity, replays events after the last valid +snapshot, republishes pending outbox rows, and reclaims only expired leases. Tasks with corrupt +history are quarantined read-only and surfaced as `store_corrupt`. + +## Dependencies + +- Phase 0 contracts: ID encodings, event compatibility, storage/supervision ADR, retention, and + threat model. +- [Gateway API](../gateway-api/design.md): actor commands and projections. +- [Capability Broker](../capability-broker/design.md): Execution IDs, permits, approvals, and + reconciliation. +- [Cosh Core Bridge](../cosh-core-bridge/design.md): runtime binding and normalized events. +- Phase 2 ACP bridge and presentation modules consume the same ports without becoming writers. + +## Implementation work breakdown + +1. Define Task/Run/approval/event/projection newtypes and bounded codecs under the G0 + schema-first contracts decision. +2. Implement aggregate transition reducer and exhaustive transition tests. +3. Implement coordinator command serialization and optimistic revision checks. +4. Implement transactional event/snapshot/idempotency/outbox storage adapter. +5. Implement Run lease acquire/renew/reclaim and fencing checks. +6. Implement replay, snapshot validation, corruption quarantine, and migration tooling. +7. Implement projection/event cursor and outbox worker. +8. Connect Gateway, bridge, and Broker ports with deterministic fakes first. +9. Add kill-point crash matrix and uncertain-side-effect reconciliation fixtures. + +## Test strategy + +- Table-driven tests for every legal and illegal state transition. +- Model/property tests comparing command replay with event reduction. +- Concurrent writer tests proving only one expected revision and one lease fence wins. +- Idempotency tests for same/different digest and post-commit response loss. +- Kill-point tests before event append, between event/snapshot/outbox writes, after commit, during + lease renewal, and before delivery acknowledgment. +- Corruption tests for truncated events, bad checksums, unknown required schema, and scope mismatch. +- Reconciliation tests proving expired lease never automatically repeats an unknown execution. +- Projection tests for replay, cursor expiry, duplicate delivery, and redaction. +- Migration fixtures for every committed store schema generation. + +## Open questions + +| Question | Owner | Default pending decision | +| --- | --- | --- | +| Which embedded store is accepted? | Phase 0 storage ADR owner | SQLite WAL candidate; port-first design. | +| What is the event/snapshot compaction threshold? | Task storage owner | Retain immutable security-relevant control events; benchmark snapshots. | +| How long are idempotency receipts retained? | Gateway/task owners | Longer than maximum channel retry and offline window. | +| Can a Task have concurrent Runs? | Runtime/product owners | No in Phase 1; one active Run per Task. | +| When may uncertain execution be retried? | Broker/security owner | Only after typed reconciliation or explicit operator decision. | +| Which approval roles may act across channels? | Identity/security owner | Exact actor only until delegation is specified. | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design_zh.md new file mode 100644 index 0000000000..2cc3a328e8 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-1/task-execution-plane/design_zh.md @@ -0,0 +1,354 @@ +# Phase 1 Task Execution Plane 设计 + +[English](design.md) | [验收基线](acceptance_zh.md) + +## 状态与决策 + +本文基于上游提交 `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 规划 Phase 1。当前工作树候选已包含 +首个 Task reducer 与 SQLite persistence 切片,但还不是完整 Phase 1 服务。Task Execution Plane 使用户 +意图独立于 channel connection、Agent process、provider session、PTY 或 OS execution attempt 持久 +存在。规划中的 `TaskCoordinator` 仍是 Task aggregate 的唯一 writer。其他模块只能提交 typed command, +并观察已经 commit 的 event 或 projection。 + +首个 deployment 可以把 coordinator、runner、projection worker 和 store 放在 `cosh-gateway` 进程中, +但仍保持各自 ownership 与 port boundary。 + +### 首个已实现切片 + +- `task/aggregate.rs` 基于共享 `TaskEventEnvelope` contract 提供确定性 reducer,错误时不修改原 + aggregate。它强制 revision 连续、Task/correlation identity、显式 `WaitingApproval`、Task terminal + closure,以及先记录 Run terminal fact 再关闭 Task。 +- `storage/task_store.rs` 通过 `&mut SqliteTaskStore` 独占可变 SQLite connection。`BEGIN IMMEDIATE` + 原子 append event、更新 snapshot、记录 actor-scoped idempotency receipt 并插入稳定 Outbox row。 +- 完全相同的 retry 在检查已经过期的 revision 之前返回 stored receipt。同一 actor/key 使用不同 digest + 会失败,optimistic revision 不匹配也会失败。 +- Recovery 解码全部 versioned event,重新执行 reducer,并在重建 projection 与 snapshot 不一致时 + fail closed。 +- Run lease、`TaskCoordinator` mailbox、Outbox dispatch/lease worker、approval authorization、execution + reconciliation 与 corruption quarantine 留给后续切片。 + +## 目标 + +- 用显式版本持久保存 Task、Run、input、approval、runtime binding、execution reference 和 terminal + outcome。 +- 在 daemon、Agent、presentation 或弱网中断后安全恢复。 +- 所有 Task transition 经过单一 writer 串行化,并拒绝 stale command。 +- 原子 append Task event、更新 snapshot、存储 idempotency result 并写入 outbox delivery。 +- 使用可续租 Run lease 和 fencing token,但不把 lease expiry 当成 OS 副作用可安全重放的证明。 +- Task event 保持有界,并与 security audit record 和 raw stream storage 分离。 + +## 非目标 + +- 替换 `SessionStore` 中的 provider conversation persistence。 +- 授权 OS operation 或签发 permit;这属于 `CapabilityBroker`。 +- 拥有 child process 或转换 cosh-core/ACP message;这属于 runtime bridge。 +- 存储原始 model stream、terminal output、credential、environment snapshot 或 file body。 +- 提供 exactly-once OS side effect。无法确定的副作用必须 reconciliation。 +- 在 Phase 0 storage ADR 接受前确定最终 embedded database。 + +## 当前源码证据 + +| `6c115aef` 的证据 | 可复用行为 | Task Plane 缺口 | +| --- | --- | --- | +| [`cosh-core/session.rs`](../../../../../crates/cosh-core/src/session.rs) | 按 workspace 隔离的 `ProviderSessionId`、schema version、generation 和 typed health/error。 | Provider transcript 不是 Task 或 Run。 | +| [`cosh-core/session/store.rs`](../../../../../crates/cosh-core/src/session/store.rs) | Lock、optimistic generation、有界文件、atomic replace 和 scope validation。 | 文件 envelope 无法原子承载 Task event、idempotency、lease 与 outbox row。 | +| [`cosh-core/session_control.rs`](../../../../../crates/cosh-core/src/session_control.rs) | 有界 list/inspect/validate/clear management protocol。 | 无 Task command 或持久 execution lifecycle。 | +| [`cosh-shell/runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) | 单个交互 Shell 的 in-memory inline runtime state。 | State 属于进程内和 presentation。 | +| [`cosh-shell/adapter/mod.rs`](../../../../../crates/cosh-shell/src/adapter/mod.rs) | Agent run handle 和 event callback pattern。 | Lifecycle 由 Shell 拥有且不持久。 | +| [`cosh-types/audit/event.rs`](../../../../../crates/cosh-types/src/audit/event.rs) | Audit identity 已有 run/request/tool use 等有界 correlation field。 | Audit event 是证据,不是 aggregate state 或 delivery queue。 | + +基线上不存在 `Task`、`TaskId`、持久 `RunId`、`TaskCoordinator`、`TaskEventStore`、lease table、 +idempotency ledger、Task projection 或 outbox。 + +## Aggregate ownership 与 ports + +```mermaid +flowchart TB + G["Gateway TaskCommandPort"] --> C["TaskCoordinator\nTask 唯一 writer"] + R["RuntimeEventPort"] --> C + B["BrokerResultPort"] --> C + A["ApprovalCommandPort"] --> C + C --> S["TaskStore transaction"] + S --> E[("Task events")] + S --> P[("Task snapshot")] + S --> I[("Idempotency ledger")] + S --> L[("Run leases")] + S --> O[("Outbox")] + O --> W["Projection / delivery workers"] + C --> AR["AgentRuntimePort"] + C --> CB["CapabilityBrokerPort"] +``` + +`TaskCoordinator` 拥有 aggregate validation 和 event decision,不拥有 channel transport、Agent wire +parsing、policy evaluation、OS execution 或 UI rendering。进程内使用 per-Task actor mailbox 串行 command。 +Store 仍强制 `expected_revision`,因此第二个进程或 stale lease 不能绕过约束。 + +概念 ports 如下: + +```rust +trait TaskCommandPort { + async fn execute(&self, command: TaskCommand) -> Result; +} + +trait TaskEventStore { + async fn load(&self, task_id: TaskId) -> Result; + async fn commit(&self, batch: TaskCommit) -> Result; +} + +trait TaskLeasePort { + async fn acquire(&self, run_id: RunId, owner: WorkerId) -> Result; + async fn renew(&self, lease: RunLease) -> Result; +} + +trait TaskProjectionPort { + async fn get(&self, task_id: TaskId) -> Result; + async fn events(&self, query: EventQuery) -> Result; +} +``` + +这些签名定义职责,不代表最终 Rust syntax。 + +Neutral Task/Run ID 与 cross-process command/event DTO 遵循 Phase 0 G0 schema-first 决策;最终名称和 +crate boundary 接受后归计划中的 side-effect-free `cosh-gateway-contracts` leaf 所有。Aggregate reducer、 +storage record、lease 与 coordinator internal 留在 `cosh-gateway` 私有范围,它们不是 wire contract,也不 +进入现有 `cosh-types`。 + +## Identity 与 aggregate schema + +ID 使用具有 canonical text encoding 的 typed newtype,不同 ID type 之间不能赋值。 + +| ID | Authority | 生命周期 | +| --- | --- | --- | +| `TaskId` | Task Coordinator | 持久用户意图。 | +| `RunId` | Task Coordinator | Task 下的一次尝试。 | +| Task event `MessageId` | Contract producer | 一个 immutable aggregate event。 | +| `AgentSessionId` | Runtime bridge | Runtime conversation binding,不是 Task identity。 | +| `ApprovalId` | Task Coordinator | 一个持久 gate。 | +| `ExecutionId` | Capability Broker | 一次 side-effect attempt。 | +| `IdempotencyKey` | Command initiator | Actor scope 内的 command replay namespace。 | +| `DeliveryId` | Task transaction | 一个 outbox intent。 | + +Aggregate snapshot 只包含有界 control data: + +```text +Task { + task_id, tenant_id, actor_id, target_ref, + state, revision, created_at, updated_at, + active_run_id?, latest_input_ref?, + pending_approval_ids[], runtime_binding_ref?, + result_summary?, failure? +} + +Run { + run_id, attempt, state, runtime_profile, + started_at?, finished_at?, lease_fence, + agent_session_id?, last_runtime_cursor?, + execution_ids[], terminal_reason? +} +``` + +`latest_input_ref` 指向有界且脱敏的 content storage。Event 只包含 hash、size 和 opaque evidence +reference,不包含 raw prompt、model thought、terminal buffer 或 credential。 + +## 状态机 + +```mermaid +stateDiagram-v2 + [*] --> Submitted + Submitted --> Queued: admitted + Queued --> Running: valid lease acquired + Running --> WaitingApproval: gate committed + WaitingApproval --> Running: valid resolution committed + WaitingApproval --> Suspended: approval expired + Running --> WaitingInput: elicitation committed + WaitingInput --> Running: input appended + Running --> Suspended: runtime unavailable or uncertain effect + Suspended --> Queued: explicit retry or reconciled resume + Running --> Succeeded: result committed + Running --> Failed: terminal failure committed + Submitted --> Cancelled: cancel + Queued --> Cancelled: cancel + Running --> Cancelled: cancellation confirmed + WaitingApproval --> Cancelled: cancel + WaitingInput --> Cancelled: cancel +``` + +Terminal state 为 `Succeeded`、`Failed` 和 `Cancelled`,进入后不能 reopen。`retry` 保留 `TaskId` +并创建新 `RunId`。`Suspended` 记录可恢复停止以及需要的 operator 或 policy action。Runtime 或 execution +活跃时收到 cancel,先记录 `CancellationRequested`;只有 owning bridge/target 确认 settle,或经过 review +的 reconciliation policy 宣告 terminal,才 commit `Cancelled`。 + +## Command 与 event schema + +每个 command 包含 `tenant_id`、`actor_context`、`request_id`、已知时的 `expected_revision`、 +`issued_at` 与 `deadline`。核心命令包括: + +```text +CreateTask, AdmitTask, AcquireRun, RenewRunLease, +AppendInput, RequestApproval, ResolveApproval, +RecordRuntimeBinding, RecordRuntimeEvent, +RecordExecutionPlanned, RecordExecutionResult, +RequestCancellation, ConfirmCancellation, +SuspendRun, RetryRun, CompleteRun, FailRun +``` + +只有 coordinator internal principal 可以发 lease/runtime/execution command。Gateway actor 可以 create、 +append、cancel、retry 和 resolve 分配给自己的 approval。 + +代表性的 immutable event 为: + +```text +TaskSubmitted, TaskQueued, RunStarted, RunLeaseRenewed, +InputAppended, RuntimeBound, RuntimeEventRecorded, +ApprovalRequested, ApprovalResolved, ApprovalExpired, +ExecutionPlanned, ExecutionResultRecorded, ExecutionUncertain, +CancellationRequested, RunCancelled, RunSuspended, +RunSucceeded, RunFailed, RunRetryQueued, TaskSucceeded, TaskFailed, TaskCancelled +``` + +每个 event 包含 `schema`、`schema_version`、`task_id`、`event_id`、`sequence`、`task_revision`、 +`occurred_at`、`causation_id`、`correlation_id`、actor/runtime principal 和有界 typed payload。同一 +schema generation 内忽略未知 optional field;遇到未知 required event type 时停止 replay,并将 Task 标记 +为 incompatible。 + +## Transaction 与 optimistic concurrency + +一个 accepted mutation 执行以下 transaction: + +1. 在 store write serialization 下读取 Task row 和 latest revision。 +2. 从 idempotency ledger 解析 `(tenant, principal, request_id)`。 +3. 同一 request 携带不同 canonical command digest 时拒绝。 +4. 校验 `expected_revision`、state transition、lease fence、approval state 和 referenced ID。 +5. Append sequence 连续的一个或多个 immutable event。 +6. 以 `revision + 1` 或 event batch 最终 revision 替换 projection/snapshot。 +7. 在 idempotency ledger 插入 command receipt。 +8. 在 outbox 插入全部 projection/delivery intent。 +9. 原子 commit 后再发布 in-memory notification。 + +Caller 无法确定 transaction outcome 时,用相同 `IdempotencyKey` retry 即可返回 stored receipt。Store conflict +触发 reload 与 command re-evaluation,不能 blind append event。 + +首个 storage 切片已经用 SQLite WAL、`synchronous=FULL`、foreign key、strict table 与单一 owned write +connection 提供 atomic uniqueness 和 transaction。Phase 0 ADR 在最终 exit 前仍需补齐 backup/restore、 +checkpoint health、corruption quarantine 与 operational runbook。Public contract 不暴露 SQLite type。 + +## Idempotency 语义 + +- Scope 为 `(ActorId, IdempotencyKey)`;tenant/workspace authorization 保留在 ingress。 +- Canonical digest 包含 command type、Task/Run reference、normalized payload 和 target reference,排除 + trace ID 与 deadline。 +- Successful receipt 与 domain-error receipt 的保留时间覆盖 channel retry policy。 +- In-progress ledger row 不能脱离 transaction owner;不存在 transaction 外的 two-phase placeholder。 +- Runtime event 使用 `(RuntimeInstanceId, source_sequence)` 或 bridge 签发的稳定 event identity 去重。 +- Approval resolution 采用 first-valid-terminal-wins。后续 duplicate 返回 stored decision;冲突决定返回 + `approval_already_resolved`。 +- Execution result 按 `ExecutionId` 与 result revision 去重。 + +## Run lease 与 fencing 语义 + +`RunLease` 包含 `run_id`、`owner_id`、`fence`、`acquired_at`、`expires_at` 和 renewal deadline。每次 +acquire 都递增 `fence`。所有 runtime command 和 coordinator callback 都携带 fence;即使 stale owner +process 仍存活也会被拒绝。 + +Lease expiry 只允许另一个 worker reconciliation 并取得 orchestration ownership,不能授权重放 +`ExecutionId`、重发 prompt 或重用 permit。Retry 前新 worker 必须向 runtime bridge 和 Broker 查询其 +durable/observable status。未知 side effect 产生 `ExecutionUncertain` 与 `Suspended`,不能自动 retry。 + +Renewal 使用有界 jitter 并在 expiry 前停止。无法 renew 的 worker 停止接收新工作并请求 cancel 自己拥有 +的 runtime operation;fence stale 后不能继续写入。 + +## Outbox 与 projection 语义 + +Task transaction 写入 `DeliveryIntent` row,包括 `delivery_id`、`task_id`、event range、presentation +kind、destination binding reference、redaction profile、attempt count 与 next attempt time。不能存储 +channel credential 或无界 rendered body。 + +Projection worker 从 event 和有界 evidence 生成 channel-neutral view。Delivery 采用 at-least-once。 +支持时使用稳定 `DeliveryId` 与 destination idempotency token 抑制 duplicate。Failed 或 dead-lettered +delivery 只改变 delivery projection,不能 fail 或 rewind Task。Event consumer 存储 +`(consumer_id, task_id, sequence)` checkpoint 并容忍 replay。 + +## Approval、安全与 audit + +- `ApprovalRequest` 及其 resolution 属于 Task state;card 或 callback 只是 presentation。 +- 只有 coordinator 能 commit approval resolution,并校验 actor/delegation、Task 和 Run state、expiry、 + operation digest、target binding 与 current policy revision。 +- Committed approval 是 Broker 输入,不是可执行 permit。 +- 每个 side-effect event 引用 `ExecutionId`;对应 security audit event 携带 Task/Run/Execution correlation, + 但不成为 Task source of truth。 +- Task storage 权限仅开放给 daemon account。Open 或 query record 前校验 tenant 与 workspace scope。 +- 存储的 text 和 failure detail 有界且脱敏。Secret-bearing data 只使用 external secret reference,不能进入 + event 或 outbox。 +- Corrupt、unsupported 或 scope-mismatched history fail closed,但仍可以 inspect。 + +## Error model + +稳定分类包括 `invalid_command`、`not_found`、`forbidden`、`version_conflict`、 +`idempotency_conflict`、`invalid_transition`、`stale_lease`、`approval_expired`、 +`approval_already_resolved`、`runtime_unavailable`、`execution_uncertain`、`store_busy`、 +`store_corrupt`、`incompatible_schema` 和 `internal`。 + +Error 指明 client 应使用同 request retry、刷新 revision 后 retry、请求 reconciliation 或停止。Timeout +不能宣称 command 未 commit。Store 与 serialization error 包含有界 developer context,但不能包含 prompt、 +terminal output、credential 或 filesystem content。 + +## 迁移与恢复 + +1. 固化 Phase 0 ID/event/storage ADR,并增加纯 schema type。 +2. 创建空的 versioned Task store,不导入 provider `SessionStore` record。 +3. 在 in-memory fake 后增加 coordinator replay、snapshot、command ledger、lease 和 outbox。 +4. 增加 local persistent adapter 与 crash fixture。 +5. 依次连接 Gateway command、`CoshCoreBridge` 和 Broker callback。 +6. Opt-in migration 期间保留 direct `cosh-shell` 和现有 CLI flow。 + +Provider session 可以通过 `AgentSessionId` 和 opaque binding metadata 关联,但不能自动转换为 Task。 +Rollback 时禁用 Task ingress 并保留现有 provider session file。Store schema migration 必须使用 forward +backup 和 offline validator,不能静默 downgrade 新 event generation。 + +Daemon 启动时校验 schema 与 store integrity,重放最后一个有效 snapshot 后的 event,重新发布 pending +outbox row,并且只 reclaim expired lease。History corrupt 的 Task 进入 read-only quarantine 并显示 +`store_corrupt`。 + +## 依赖 + +- Phase 0 contract:ID encoding、event compatibility、storage/supervision ADR、retention 与 threat model。 +- [Gateway API](../gateway-api/design_zh.md):actor command 与 projection。 +- [Capability Broker](../capability-broker/design_zh.md):Execution ID、permit、approval 与 reconciliation。 +- [Cosh Core Bridge](../cosh-core-bridge/design_zh.md):runtime binding 与 normalized event。 +- Phase 2 ACP bridge 与 presentation module 使用相同 port,但不能成为 writer。 + +## 实现任务分解 + +1. 按 G0 schema-first contracts 决策定义 Task/Run/approval/event/projection newtype 与有界 codec。 +2. 实现 aggregate transition reducer 和 exhaustive transition test。 +3. 实现 coordinator command serialization 与 optimistic revision check。 +4. 实现 transactional event/snapshot/idempotency/outbox storage adapter。 +5. 实现 Run lease acquire/renew/reclaim 与 fencing check。 +6. 实现 replay、snapshot validation、corruption quarantine 与 migration tooling。 +7. 实现 projection/event cursor 与 outbox worker。 +8. 首先使用 deterministic fake 连接 Gateway、bridge 与 Broker port。 +9. 增加 kill-point crash matrix 和 uncertain-side-effect reconciliation fixture。 + +## 测试策略 + +- 为每个合法和非法 state transition 建立 table-driven test。 +- Model/property test 比较 command replay 与 event reduction。 +- Concurrent writer test 证明只有一个 expected revision 与一个 lease fence 获胜。 +- 覆盖同/不同 digest 与 post-commit response loss 的 idempotency test。 +- 在 event append 前、event/snapshot/outbox write 之间、commit 后、lease renewal 中和 delivery ack 前进行 + kill-point test。 +- 覆盖 truncated event、bad checksum、unknown required schema 和 scope mismatch 的 corruption test。 +- Reconciliation test 证明 expired lease 不会自动重复 unknown execution。 +- Projection test 覆盖 replay、cursor expiry、duplicate delivery 和 redaction。 +- 为每个已 commit store schema generation 保留 migration fixture。 + +## 开放问题 + +| 问题 | Owner | 待决默认值 | +| --- | --- | --- | +| 接受哪种 embedded store? | Phase 0 storage ADR owner | SQLite WAL candidate;port-first design。 | +| Event/snapshot compaction threshold 是什么? | Task storage owner | 保留 security-relevant control event;benchmark snapshot。 | +| Idempotency receipt 保留多久? | Gateway/task owner | 长于最大 channel retry 与 offline window。 | +| 一个 Task 能否有 concurrent Run? | Runtime/product owner | Phase 1 不允许;每个 Task 只有一个 active Run。 | +| 何时可以 retry uncertain execution? | Broker/security owner | 仅在 typed reconciliation 或 explicit operator decision 后。 | +| 哪些 approval role 可跨渠道操作? | Identity/security owner | Delegation 规格完成前只允许 exact actor。 | diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance.md new file mode 100644 index 0000000000..4c3f8db8d3 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance.md @@ -0,0 +1,129 @@ +# Phase 2 ACP Client Bridge Acceptance Report + +[中文版](acceptance_zh.md) + +Related design: [ACP Client Bridge design](design.md). + +## 1. Report scope + +- Baseline reviewed: `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Review date: 2026-08-13 +- Change type: first library implementation slice plus design evidence +- Implementation acceptance: **NOT ACCEPTED** + +This report records current readiness and the evidence required to exit Phase +2. It does not claim production ACP support or an installed runtime entrypoint. +The narrower [local ACP MVP report](../../phase-1/acp-mvp/acceptance.md) tracks +the first usable local path separately. + +## 2. Baseline evidence + +The baseline contains a shell-owned `AgentAdapter`, streamed `AgentEvent` +types, a cosh-core adapter, an internal JSONL protocol, and provider session +persistence. It contains no ACP dependency, ACP client, ACP JSON-RPC router, +ACP stdio process, capability negotiation, or conformance suite. + +`CONTROL_PROTOCOL_VERSION = 1` in cosh-core is an internal shell-to-core +contract. It is not evidence of ACP `protocolVersion: 1` support. + +The candidate worktree adds official `agent-client-protocol = 2.0.0`, raises +the cosh-ng Rust/RPM baseline to 1.88, and implements `AcpV1Codec` plus +`AcpV1RuntimeBridge` and fixed installed-adapter profiles. The bridge embeds +the sole `RuntimeSupervisor` lifecycle implementation +and has focused fixtures for v1 negotiation, a supervised stdio exchange, +session/prompt/update, permission correlation, cancellation settlement, +identity mismatch, unsupported callbacks, and malformed/oversized frames. + +## 3. Current readiness + +| Area | Baseline status | Acceptance status | Evidence needed to pass | +| --- | --- | --- | --- | +| Neutral `AgentRuntimePort` | Not present in production | **PARTIAL** | Neutral contracts exist; coordinator/port implementation and fixtures remain | +| ACP SDK/toolchain ADR and dependency | Not present | **PASS for first slice** | SDK 2.0.0 and Rust 1.88 are pinned; release/license review remains a PR gate | +| Built-in runtime profiles | Not present | **PARTIAL** | Installed `codex-acp` and `claude-agent-acp` resolution, canonical paths, and environment allowlists have focused tests; no installed COSH entrypoint or distribution/version policy exists | +| ACP v1 initialization | Not present | **PARTIAL** | Exact v1 request, response, and wrong-version rejection pass focused tests; real-Agent conformance remains | +| Capability snapshot | Not present | **PARTIAL** | Stable capability copying and additional-root gating exist; complete method matrix remains | +| stdio transport | Internal JSONL only | **PARTIAL** | Fake Agent exchange uses the sole hardened supervisor; crash/backpressure suite remains | +| ACP session binding | Provider session state only | **PARTIAL** | One opaque ACP session is fenced inside the codec; durable `AgentSessionId` binding remains | +| Prompt and update mapping | Shell-specific events only | **PARTIAL** | Official v1 types validate text prompt/update/stop; neutral Runtime/Task mapping remains | +| Permission callback governance | Shell approval bridge only | **PARTIAL** | Request/option correlation and cancel settlement exist; Approval/Broker integration remains | +| Filesystem callbacks | No ACP callback path | **NOT IMPLEMENTED** | Broker-only read/write tests and escape PoCs | +| Terminal callbacks | No ACP callback path | **NOT IMPLEMENTED** | Governed execution handle lifecycle tests | +| Cancellation settlement | Provider-specific cancellation exists | **PARTIAL** | Pending permission callbacks receive ACP cancelled outcomes; prompt/process race suite remains | +| Load/resume/replay | cosh-core provider resume only | **NOT IMPLEMENTED** | Capability-gated ACP load/resume tests | +| Runtime supervision | Shell-owned process lifecycle | **PARTIAL** | ACP reuses `RuntimeSupervisor`; restart, lease-loss, and recovery remain | +| Conformance suite | Not present | **PARTIAL** | Official SDK types and focused fixtures pass; upstream conformance corpus/real Agent remains | + +The candidate proves the basic ACP v1 transport shape, but it does not satisfy +the end-to-end governance, durability, recovery, or attachment exit criteria. + +## 4. Exit criteria + +| ID | Criterion | Required proof | +| --- | --- | --- | +| ACP-01 | Every connection starts with ACP `initialize` using wire version `1` | Exact request/response fixtures and wrong-version rejection | +| ACP-02 | SDK package version and wire version remain independent | Dependency policy test or review plus documentation assertion | +| ACP-03 | First release uses local stdio and does not depend on draft Streamable HTTP | Configuration and transport integration tests | +| ACP-04 | ACP `sessionId` maps only to `AgentSessionId` | Type-level API review and cross-ID negative tests | +| ACP-05 | `TaskId`, `RunId`, and event sequence survive Agent process restart | Durable recovery integration test | +| ACP-06 | Optional ACP methods are called only when advertised | Capability matrix tests | +| ACP-07 | Prompt chunks, plans, tool calls, usage, and stop reasons map deterministically | Golden mapping fixtures | +| ACP-08 | `session/request_permission` always enters Approval and Broker policy | End-to-end fake Agent test and direct-call prohibition review | +| ACP-09 | `fs/*` never performs direct bridge filesystem I/O | Broker fake assertions plus traversal and symlink PoCs | +| ACP-10 | `terminal/*` uses target-bound governed execution handles | Create/output/wait/kill/release lifecycle tests | +| ACP-11 | Cancel settles outstanding prompt, permission, and callback work | Race and timeout tests with no late execution | +| ACP-12 | Malformed or contaminating stdout fails closed; stderr is bounded and redacted | Adversarial subprocess fixtures | +| ACP-13 | Backpressure cannot grow memory without a bound | Saturation test with defined termination result | +| ACP-14 | Load replay and resume-without-replay are distinguishable | Event flags and presentation replay test | +| ACP-15 | Unsupported recovery never silently resends a prompt | Crash/restart test that reaches explicit blocked state | +| ACP-16 | Disabling the ACP runtime profile restores the existing runtime paths | Rollback smoke test | + +All criteria are mandatory for Phase 2 exit. Optional ACP features may remain +disabled, but any advertised feature must pass its complete callback and +governance criteria. + +## 5. Required test evidence + +The implementation acceptance report must record: + +- full candidate commit SHA; +- exact ACP SDK crate version from `Cargo.lock`; +- exact targeted test commands and test counts; +- official ACP v1 schema or conformance fixture revision; +- supported capability matrix; +- subprocess limits for line size, stderr, queue depth, and timeouts; +- adversarial proof for path escape, ID confusion, permission spoofing, output + contamination, duplicate execution, and cancellation races; +- untested optional ACP features and transports. + +Current focused command: + +```text +cargo +1.88.0 test --package cosh-gateway runtime::acp +``` + +Result on the uncommitted candidate worktree: 13 passed, 0 failed. This is +first-slice evidence only and is not the full Phase 2 conformance suite. + +## 6. Manual and live validation + +No provider, ECS, manual terminal, or screenshot validation was requested or +performed for this implementation slice. A future live gate must not be marked +passed until it runs the exact candidate commit and records sanitized evidence. + +## 7. Remaining blockers + +- Phase 0 Runtime Port, ID, event, persistence, and supervision contracts must + be accepted first. +- Phase 1 Task Plane, Capability Broker, Approval Service, and Execution Target + must be available. +- Fixed executable names and local resolution are implemented; signed/versioned + adapter distribution policy and installed-entrypoint integration remain. +- Output, terminal lifetime, and optional replay policy limits need approved + values. + +## 8. Acceptance decision + +**PARTIAL IMPLEMENTATION / NOT ACCEPTED.** The v1 codec and supervised stdio +bridge are real candidate evidence, but Phase 2 acceptance still requires all +ACP-01 through ACP-16 criteria on one candidate revision. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance_zh.md new file mode 100644 index 0000000000..8768e44dd5 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/acceptance_zh.md @@ -0,0 +1,123 @@ +# Phase 2 ACP Client Bridge 验收报告 + +[English](acceptance.md) + +相关设计:[ACP Client Bridge 设计](design_zh.md)。 + +## 1. 报告范围 + +- 审计基线:`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 审计日期:2026-08-13 +- 变更类型:第一轮 library implementation slice 与设计证据 +- 实现验收:**NOT ACCEPTED** + +本报告记录当前就绪度和退出 Phase 2 所需的证据,不代表 production ACP 支持或已安装 Runtime +entrypoint 已经存在。更窄的首个可用 local path 由 +[Local ACP MVP 报告](../../phase-1/acp-mvp/acceptance_zh.md)单独跟踪。 + +## 2. 基线证据 + +基线包含 Shell owner 的 `AgentAdapter`、流式 `AgentEvent` 类型、cosh-core +Adapter、内部 JSONL protocol 和 provider session persistence。它不包含 ACP +dependency、ACP Client、ACP JSON-RPC router、ACP stdio process、capability +negotiation 或 conformance suite。 + +cosh-core 中的 `CONTROL_PROTOCOL_VERSION = 1` 是内部 shell-to-core 契约, +不能作为支持 ACP `protocolVersion: 1` 的证据。 + +候选工作树加入官方 `agent-client-protocol = 2.0.0`,把 cosh-ng Rust/RPM baseline +提升到 1.88,并实现 `AcpV1Codec`、`AcpV1RuntimeBridge` 与固定的 installed-adapter profile。 +Bridge 内嵌唯一的 `RuntimeSupervisor` lifecycle implementation;focused fixture 覆盖 v1 negotiation、supervised stdio +exchange、session/prompt/update、permission correlation、cancellation settlement、identity +mismatch、unsupported callback 与 malformed/oversized frame。 + +## 3. 当前就绪度 + +| 领域 | 基线状态 | 验收状态 | 通过所需证据 | +| --- | --- | --- | --- | +| 中立 `AgentRuntimePort` | Production 中不存在 | **PARTIAL** | 中立 contract 已存在;coordinator/port implementation 与 fixture 仍缺 | +| ACP SDK/toolchain ADR 与 dependency | 不存在 | **第一轮 PASS** | SDK 2.0.0 与 Rust 1.88 已固定;release/license review 仍是 PR gate | +| 内置 Runtime profile | 不存在 | **PARTIAL** | 已安装 `codex-acp` 与 `claude-agent-acp` 的解析、canonical path 与 environment allowlist 有 focused test;仍无已安装 COSH entrypoint 或 distribution/version policy | +| ACP v1 初始化 | 不存在 | **PARTIAL** | Exact v1 request、response 与错误版本拒绝通过 focused test;真实 Agent conformance 仍缺 | +| Capability snapshot | 不存在 | **PARTIAL** | Stable capability copy 与 additional-root gate 已有;完整 method matrix 仍缺 | +| stdio transport | 只有内部 JSONL | **PARTIAL** | Fake Agent exchange 使用唯一 hardened supervisor;crash/backpressure suite 仍缺 | +| ACP session binding | 只有 provider session state | **PARTIAL** | Codec 内已约束单一 opaque ACP session;持久 `AgentSessionId` binding 仍缺 | +| Prompt 与 update 映射 | 只有 Shell-specific event | **PARTIAL** | 官方 v1 类型校验 text prompt/update/stop;中立 Runtime/Task mapping 仍缺 | +| Permission callback 治理 | 只有 Shell approval bridge | **PARTIAL** | Request/option correlation 与 cancel settlement 已有;Approval/Broker integration 仍缺 | +| Filesystem callback | 没有 ACP callback 路径 | **NOT IMPLEMENTED** | Broker-only read/write test 和逃逸 PoC | +| Terminal callback | 没有 ACP callback 路径 | **NOT IMPLEMENTED** | Governed execution handle 生命周期测试 | +| Cancellation 结算 | 已有 provider-specific cancellation | **PARTIAL** | 待决 permission callback 会收到 ACP cancelled outcome;prompt/process race suite 仍缺 | +| Load/resume/replay | 只有 cosh-core provider resume | **NOT IMPLEMENTED** | Capability-gated ACP load/resume test | +| Runtime supervision | Shell owner 的 process lifecycle | **PARTIAL** | ACP 复用 `RuntimeSupervisor`;restart、lease-loss 与 recovery 仍缺 | +| Conformance suite | 不存在 | **PARTIAL** | 官方 SDK 类型与 focused fixture 已通过;上游 corpus/真实 Agent 仍缺 | + +候选实现证明了基本 ACP v1 transport shape,但仍不满足端到端治理、持久化、恢复或 +Attachment exit criteria。 + +## 4. Exit Criteria + +| ID | 标准 | 必需证明 | +| --- | --- | --- | +| ACP-01 | 每个 connection 首先发送 wire version `1` 的 ACP `initialize` | 准确 request/response fixture 和错误版本拒绝 | +| ACP-02 | SDK package 版本和 wire version 保持独立 | Dependency policy test 或 review 加文档断言 | +| ACP-03 | 首版使用本地 stdio,不依赖草案状态的 Streamable HTTP | 配置和 transport integration test | +| ACP-04 | ACP `sessionId` 只能映射到 `AgentSessionId` | Type-level API review 和 ID 混淆负向测试 | +| ACP-05 | `TaskId`、`RunId` 与 event sequence 在 Agent process 重启后保持 | 持久恢复 integration test | +| ACP-06 | Optional ACP method 只在对端声明后调用 | Capability matrix test | +| ACP-07 | Prompt chunk、plan、tool call、usage 和 stop reason 确定性映射 | Golden mapping fixture | +| ACP-08 | `session/request_permission` 始终进入 Approval 与 Broker policy | 端到端 fake Agent test 和 direct-call prohibition review | +| ACP-09 | `fs/*` 永不在 Bridge 内直接执行 filesystem I/O | Broker fake 断言以及 traversal、symlink PoC | +| ACP-10 | `terminal/*` 使用 target-bound governed execution handle | Create/output/wait/kill/release 生命周期测试 | +| ACP-11 | Cancel 结算未完成 prompt、permission 与 callback 工作 | 没有 late execution 的 race 和 timeout test | +| ACP-12 | Malformed 或污染 stdout 时 fail closed;stderr 有界且 redaction | Adversarial subprocess fixture | +| ACP-13 | Backpressure 不会导致无界内存增长 | 带明确定义终止结果的 saturation test | +| ACP-14 | Load replay 和 resume-without-replay 可区分 | Event flag 和 Presentation replay test | +| ACP-15 | 不支持恢复时绝不静默重发 prompt | 到达显式 blocked 状态的 crash/restart test | +| ACP-16 | 禁用 ACP Runtime profile 可恢复现有 Runtime 路径 | Rollback smoke test | + +所有标准都是退出 Phase 2 的强制条件。Optional ACP feature 可以保持关闭,但 +任何已声明 feature 都必须通过完整 callback 和治理标准。 + +## 5. 必需测试证据 + +实现验收报告必须记录: + +- Candidate 完整 commit SHA; +- `Cargo.lock` 中准确的 ACP SDK crate 版本; +- 准确的 targeted test command 和 test count; +- 官方 ACP v1 schema 或 conformance fixture revision; +- 已支持 capability matrix; +- Line size、stderr、queue depth 和 timeout 的 subprocess limit; +- Path escape、ID confusion、permission spoofing、output contamination、重复 + execution 和 cancellation race 的 adversarial proof; +- 尚未测试的 optional ACP feature 和 transport。 + +当前 focused command: + +```text +cargo +1.88.0 test --package cosh-gateway runtime::acp +``` + +未提交候选工作树结果为 13 passed、0 failed。这只是第一轮切片证据,不是完整 +Phase 2 conformance suite。 + +## 6. 手工与在线验证 + +本实现切片没有请求或执行 provider、ECS、手工 Terminal 或 screenshot 验证。 +未来 live gate 必须对准确 candidate commit 运行并记录脱敏证据后才能标记通过。 + +## 7. 剩余 Blocker + +- 必须先验收 Phase 0 Runtime Port、ID、event、persistence 与 supervision + contract。 +- 必须具备 Phase 1 Task Plane、Capability Broker、Approval Service 与 + Execution Target。 +- 固定 executable name 与 local resolution 已实现;signed/versioned adapter distribution policy + 和 installed-entrypoint integration 仍缺。 +- Output、terminal lifetime 与 optional replay policy limit 需要批准具体值。 + +## 8. 验收决定 + +**PARTIAL IMPLEMENTATION / NOT ACCEPTED。** v1 codec 与 supervised stdio bridge +构成真实候选证据;只有在同一 candidate revision 上为 ACP-01 至 ACP-16 提供全部 +实现证据后,才能验收 Phase 2。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design.md new file mode 100644 index 0000000000..774d6e611f --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design.md @@ -0,0 +1,430 @@ +# Phase 2 ACP Client Bridge Design + +[中文版](design_zh.md) + +Status: first implementation slice in the uncommitted candidate worktree; +not present on baseline `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +and not accepted as a production bridge. + +Related documents: [phase plan](../../README.md), +[acceptance report](acceptance.md), and the narrower +[local ACP Runtime MVP](../../phase-1/acp-mvp/design.md). + +## 1. Decision + +The ACP Client Bridge makes COSH an ACP client behind the neutral +`AgentRuntimePort`. It launches a local Agent subprocess, speaks ACP v1 over +newline-delimited JSON-RPC on stdio, and translates the Agent lifecycle into +COSH Task events. The bridge never owns Tasks, approvals, OS policy, shell +PTYs, or Web delivery. + +The version contract has two independent axes: + +| Axis | Phase 2 decision | +| --- | --- | +| ACP wire protocol | v1; send `initialize.protocolVersion = 1` | +| Rust SDK | Pin official `agent-client-protocol = 2.0.0`; cosh-ng MSRV and RPM build baseline are Rust 1.88 | +| Capability evolution | Negotiate at initialization; omitted means unsupported | +| Transport | Local subprocess stdio only | +| Streamable HTTP | Not a Phase 2 dependency; the transport remains a draft proposal | +| ACP v2 | Out of scope | + +An SDK package version is not an ACP wire version. No code or configuration +may infer wire compatibility from a crate or schema artifact version. + +## 2. Goals and non-goals + +### Goals + +- Run conforming ACP v1 Agents without making their types the COSH domain + model. +- Preserve a durable `TaskId` and `RunId` across Agent process restarts. +- Map ACP sessions only to `AgentSessionId` bindings. +- Stream Agent messages, plans, tool calls, usage, and terminal references as + ordered Runtime events. +- Route every permission, filesystem, and terminal callback through the COSH + Capability Broker and Approval Service. +- Fail closed when version, capability, identity, or callback scope cannot be + proven. +- Keep the existing cosh-core bridge available as a separate runtime adapter. + +### Non-goals + +- Using ACP as the Gateway API for Shell, Web, DingTalk, or Feishu. +- Making an ACP connection or session the durable Task source of truth. +- Supporting remote ACP transports, ACP v2, or custom ACP extensions in the + first implementation. +- Giving an ACP Agent direct access to a host PTY or filesystem. +- Translating the internal cosh-core JSONL protocol in place. It remains a + separate bridge. +- Guaranteeing process-transparent resume when an Agent does not advertise a + compatible load or resume capability. + +### Implemented first-slice boundary + +The candidate adds a synchronous `AcpV1Codec` and `AcpV1RuntimeBridge` under +`cosh-gateway::runtime`. Official SDK v1 types validate JSON-RPC frames, while +runtime-local projections prevent SDK types from entering +`cosh-gateway-contracts`. The bridge composes one `RuntimeSupervisor`, which +remains the only child-process lifecycle implementation and continues to +enforce direct launch, cleared environment, pinned cwd, +bounded stdout/stderr, and process-group reap. + +This slice implements exact v1 initialization, immutable capability copying, +one opaque session, text prompts, validated `session/update`, prompt terminal +responses, correlated permission responses, cancel settlement, unsupported +callback rejection, and bounded fail-closed decoding. It does not yet map ACP +observations into durable Runtime/Task events or route filesystem, terminal, +and permission operations through production Broker/Approval services. + +The candidate also provides fixed built-in profiles for the installed +`codex-acp` and `claude-agent-acp` adapters. The resolver accepts only an exact +profile executable, canonicalizes it and the workspace, copies only +allowlisted environment variables, and never invokes a shell, package runner, +download, or network bootstrap. It is still a library API: no installed COSH +entrypoint or session driver invokes it. + +## 3. Current source evidence + +The pinned baseline has useful adapter and lifecycle code but no ACP +implementation. The candidate worktree additionally contains the bounded +first slice described above and pins the official SDK in `Cargo.lock`. + +| Evidence | What exists | Gap relevant to this design | +| --- | --- | --- | +| [`AgentAdapter`](../../../../../crates/cosh-shell/src/adapter/mod.rs) | A provider-neutral name, capabilities, synchronous run, and streamed event callback | It is owned by `cosh-shell`, and its request type contains shell command blocks | +| [`AgentRequest` and `AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs) | Run IDs, text deltas, tool events, questions, approvals, completion, and failure | IDs and events are process-local shell types, not durable Task contracts | +| [`CoshCoreAdapter`](../../../../../crates/cosh-shell/src/adapter/cosh_core.rs) | A persistent cosh-core subprocess adapter and provider-session recovery | The adapter is not ACP and couples lifecycle to shell-owned state | +| [`protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | Internal JSONL initialization, streaming, approvals, questions, cancellation, and result messages | `CONTROL_PROTOCOL_VERSION = 1` is a COSH-private protocol, not ACP v1 | +| [`headless.rs`](../../../../../crates/cosh-core/src/headless.rs) | Strict internal protocol negotiation and workspace-scoped provider session persistence | It cannot be exposed as ACP without a separate adapter contract | +| [`session.rs`](../../../../../crates/cosh-core/src/session.rs) | `ProviderSessionId` and versioned provider conversation persistence | A provider session is not a Task, Run, or ACP session identity | + +No ACP SDK dependency, ACP schema, `initialize` request, ACP JSON-RPC router, +or ACP conformance fixture exists at the baseline commit. + +## 4. Ownership and ports + +```mermaid +flowchart LR + TC["TaskCoordinator"] --> ARP["AgentRuntimePort"] + ARP --> ACB["AcpClientBridge"] + ACB --> SUP["RuntimeSupervisor"] + SUP --> PROC["ACP Agent subprocess"] + PROC <--> STDIO["ACP v1 JSON-RPC / stdio"] + STDIO <--> ACB + ACB --> EV["RuntimeEventSink"] + ACB --> CB["Client callback router"] + CB --> BROKER["CapabilityBrokerPort"] + CB --> APPROVAL["ApprovalPort"] + BROKER --> TARGET["ExecutionTargetPort"] +``` + +### Bridge-owned state + +- ACP process handle, stderr capture, and bounded stdout decoder. +- JSON-RPC request router and outstanding request cancellation handles. +- Negotiated protocol version and immutable connection capabilities. +- `AgentSessionId` to opaque ACP `sessionId` bindings. +- ACP message, tool-call, and terminal correlation tables scoped to a session. +- Ephemeral flow-control state and the last event sequence handed to the Task + plane. + +### State owned elsewhere + +| State | Owner | +| --- | --- | +| Task lifecycle, Run attempts, replay cursor | Task Execution Plane | +| Actor, channel, and target identity | Gateway and identity modules | +| Approval request and decision | Approval Service | +| OS authorization and permit | Capability Broker | +| OS process or typed operation | Execution Target | +| ACP subprocess restart policy | Runtime Supervisor | +| User-visible rendering and delivery | Presentation adapters | + +The bridge receives typed commands and emits typed events. It must not import +HTTP request types, terminal card models, persistence records, or channel +message types. + +## 5. Runtime command contract + +The Phase 2 bridge implements the neutral Runtime Port established in Phase 0 +and Phase 1. The conceptual commands are: + +```text +StartSession { task_id, run_id, workspace, additional_roots, runtime_profile } +ResumeSession { task_id, run_id, agent_session_id } +Prompt { task_id, run_id, agent_session_id, content, idempotency_key } +CancelRun { task_id, run_id, reason } +CloseSession { task_id, agent_session_id, reason } +TerminateRuntime { runtime_instance_id, reason } +``` + +`StartSession` returns a COSH-created `AgentSessionId` plus an opaque runtime +binding. The ACP `sessionId` is stored inside that binding and is never +returned as `TaskId` or `RunId`. + +## 6. ACP profile and capability policy + +### Initialization + +The bridge sends `initialize` first with: + +- `protocolVersion: 1`; +- COSH client implementation information; +- only the client capabilities backed by an accepted COSH implementation. + +The response is rejected when it selects a protocol version other than `1`. +Capabilities are copied into an immutable connection snapshot. A missing +optional capability is unsupported, not false-by-accident or subject to +probing. + +The initial profile requires the ACP v1 baseline session operations: +`session/new`, `session/prompt`, `session/cancel`, and `session/update`. +Optional methods such as `session/load`, `session/resume`, `session/close`, +`session/list`, `session/delete`, additional directories, config options, and +rich prompt content are called only when advertised. + +### Client capability advertisement + +The first production profile SHOULD start narrow: + +| Capability | Advertise when | +| --- | --- | +| `fs.readTextFile` | A read request can be scoped, authorized, bounded, audited, and served by the Broker path | +| `fs.writeTextFile` | A write can obtain a target-bound permit and produce durable audit evidence | +| `terminal` | All terminal methods are implemented through governed execution handles | +| rich prompt content | The Task schema and presenter can preserve the content without lossy conversion | +| elicitation/config options | Gateway commands and all attached presenters have deterministic handling | + +No capability is advertised merely because the official SDK contains its +types. + +## 7. Identity and correlation mapping + +| ACP field or object | COSH mapping | Invariant | +| --- | --- | --- | +| ACP connection | `RuntimeInstanceId` | Ephemeral; one connection may host several sessions | +| ACP `sessionId` | Opaque value inside `AgentSessionBinding` | Maps only to one `AgentSessionId` | +| JSON-RPC request `id` | `RuntimeRequestId` | Scoped to one connection; not globally durable identity | +| `session/prompt` request | One active Runtime turn for a `RunId` | Retries need a COSH idempotency key; ACP itself does not make prompts idempotent | +| ACP `messageId` | `RuntimeMessageId` with session scope | Groups chunks; must not become event sequence | +| ACP `toolCallId` | `ToolUseId` | Stable within the bound Agent session | +| ACP `terminalId` | Opaque handle bound to a Broker-created `ExecutionId` | Invalid outside its Agent session and target permit | +| permission option ID | Option in a COSH `ApprovalRequest` | Agent-provided label is display data, not authorization policy | + +The bridge must reject callbacks carrying an unknown session, tool call, +terminal, or completed request correlation. + +## 8. Lifecycle, detach, and replay + +```mermaid +stateDiagram-v2 + [*] --> Starting + Starting --> Initialized: initialize v1 accepted + Starting --> Failed: spawn or negotiation failure + Initialized --> Ready: session/new or supported resume + Ready --> Prompting: session/prompt + Prompting --> AwaitingDecision: permission or elicitation request + AwaitingDecision --> Prompting: response delivered + Prompting --> Ready: StopReason received + Prompting --> Cancelling: cancel command + Cancelling --> Ready: prompt request settles + Ready --> Detached: no presentation attachment + Detached --> Ready: presentation reattaches + Ready --> Closing: close or supervisor shutdown + Closing --> Closed + Failed --> Starting: supervisor creates a new runtime attempt +``` + +Presentation detach has no ACP wire effect. It removes a Shell or Web +subscription while the Task remains authoritative. `session/close` is used +only for an explicit lifecycle decision and only when advertised. + +After bridge or Agent restart: + +1. The Task plane starts a new Runtime attempt without changing `TaskId`. +2. The bridge initializes a new ACP connection. +3. It uses `session/resume` without replay when advertised and compatible. +4. Otherwise it may use `session/load`, whose `session/update` history is + marked as replay. +5. If neither method is available, the Run becomes recoverably blocked and + requires an explicit fresh-session decision. The bridge must not silently + resend a completed or partially executed prompt. + +ACP replay updates are normalized and appended with new COSH event sequence +numbers. Duplicate message chunks are suppressed by the scoped message ID and +content offset when available. The durable event sequence remains the only +presentation replay cursor. + +## 9. Event mapping + +| ACP input | Runtime/Task event | +| --- | --- | +| agent message chunk | `AgentMessageChunkRecorded` | +| user message chunk during load | `AgentHistoryChunkReplayed` | +| thought chunk | `AgentThoughtChunkRecorded` with redaction/presentation policy | +| plan | `AgentPlanReplaced` | +| tool call | `ToolUseDeclared` | +| tool call update | `ToolUseUpdated` | +| usage update | `AgentUsageUpdated` | +| session info update | `AgentSessionMetadataUpdated` | +| permission request | `ApprovalRequested` after policy normalization | +| prompt StopReason | `RuntimeTurnFinished` with normalized reason | +| JSON-RPC error or process exit | `RuntimeAttemptFailed` with retry classification | + +The exact serialized names are frozen by the Phase 0 schema before +implementation. Unknown ACP updates are retained as bounded diagnostic +metadata and produce a compatibility event; they are not presented as +successful tool execution. + +## 10. Permission, filesystem, and terminal callbacks + +### Permission + +`session/request_permission` creates or correlates a COSH approval. The +Approval Service evaluates actor, target, Task state, operation details, and +policy before a response is sent. `allow_always` is only offered when COSH has +a supported durable policy scope; an Agent-provided option cannot create a +broader trust rule by itself. Cancelling a prompt settles outstanding ACP +permission requests with the ACP `cancelled` outcome. + +### Filesystem + +`fs/read_text_file` and `fs/write_text_file` are translated into typed Broker +requests. Absolute paths are normalized against the session workspace and +accepted additional roots. Symlink, traversal, size, encoding, redaction, and +write-conflict policy is enforced below the bridge. The bridge never opens a +requested path directly. + +### Terminal + +`terminal/create`, `output`, `wait_for_exit`, `kill`, and `release` map to an +Execution Target handle issued after Broker evaluation. They do not attach to +the user's interactive cosh-shell PTY. Output is bounded at valid UTF-8 +boundaries, audited, and retained according to the Task policy. Release is +idempotent; closing a session releases all remaining execution handles. + +## 11. Security and approval invariants + +- ACP's trusted-editor design assumption is not an OS security boundary. +- Every callback is scoped to the initialized connection, bound Agent session, + Task, actor, target, and workspace. +- ACP Agent metadata, tool kinds, titles, raw input, and permission options are + untrusted display data. +- Environment variables and command arguments are redacted before diagnostic + persistence. +- Stdout accepts only valid bounded ACP JSON-RPC messages; malformed or + non-protocol output terminates the runtime attempt. +- Stderr is diagnostic-only, bounded, redacted, and never parsed as ACP. +- No filesystem or terminal callback bypasses the Broker even in trust mode. +- A permit is bound to one operation digest, target, actor, Task, and expiry; + it cannot be reused for another ACP callback. + +## 12. Errors, backpressure, and weak connectivity + +The first transport is local stdio, so network reconnection does not apply to +the ACP hop. Weak connectivity still affects model providers and remote +Execution Targets behind the Agent or Broker. + +| Failure | Required behavior | +| --- | --- | +| Agent executable missing | Fail the Runtime attempt before a session is bound | +| Initialization timeout or wrong version | Terminate the process and report non-retryable compatibility failure | +| Invalid JSON-RPC or stdout contamination | Fail closed and retain bounded diagnostic evidence | +| Agent process exit during prompt | Mark the attempt failed; never infer whether side effects completed | +| Provider/network loss reported by Agent | Preserve Task and Run; classify retry only from structured failure evidence | +| Slow Task event sink | Apply bounded backpressure; cancel and fail before unbounded buffering | +| Client callback timeout | Return an ACP error or cancelled outcome and record the Broker/Approval timeout | +| Duplicate callback or response | Resolve from idempotency/correlation state; never execute twice | +| Task daemon restart | Rebuild bindings from Task state, then resume/load or request a fresh session decision | + +## 13. Migration and compatibility + +1. Freeze Runtime Port commands, events, IDs, and fixtures in Phase 0. +2. Keep `CoshCoreBridge` as the default Agent runtime through Phase 1. +3. Add the ACP SDK only in the ACP-owned crate or module; do not add ACP types + to Gateway or Task public schemas. +4. Implement an in-memory fake Agent and conformance fixtures before enabling + external executables. +5. Gate ACP profiles through explicit runtime configuration and registry + metadata. +6. Roll back by disabling the ACP runtime profile. Existing cosh-core and + direct shell paths remain intact. + +Persisted bindings include a schema version and runtime kind. They do not +serialize SDK structs. An SDK minor update must pass the same wire fixtures; +changing the SDK major requires an explicit compatibility review even if ACP +wire v1 remains unchanged. + +## 14. Implementation tasks + +| Work item | Owner | Depends on | +| --- | --- | --- | +| ACP subprocess supervisor and bounded stdio channel | `RuntimeSupervisor` + `AcpClientBridge` | Phase 0 supervision ADR | +| Version and capability negotiation | `AcpClientBridge` | Protocol contract fixtures | +| Session binding repository adapter | `AcpClientBridge` + Task Plane | Identity and persistence schemas | +| Prompt/update normalizer | `AcpClientBridge` | Runtime event schema | +| Permission callback adapter | `AcpClientBridge` + Approval | Approval contract | +| Filesystem callback adapter | `AcpClientBridge` + Broker | Capability request and target scopes | +| Terminal callback adapter | `AcpClientBridge` + Execution Target | Governed execution handle contract | +| Cancellation and shutdown settlement | `RuntimeSupervisor` + `AcpClientBridge` | Runtime lease and Run state machine | +| Compatibility and conformance suite | `AcpClientBridge` | Official ACP v1 schema/SDK fixtures | +| Operator diagnostics | Presentation | Redaction and error taxonomy | + +## 15. Test strategy + +### Contract tests + +- Verify `protocolVersion: 1`, exact version rejection, and capability omission. +- Validate every supported message against the official ACP v1 schema. +- Prove the selected SDK artifact and later upgrades do not change the accepted + ACP v1 wire fixtures. +- Verify all ID mappings reject cross-session and cross-Task confusion. + +### Integration tests + +- Launch a deterministic fake ACP Agent over stdio. +- Exercise new, prompt, streaming, permission, cancellation, close, process + crash, load replay, and resume-without-replay. +- Assert filesystem and terminal requests reach only the Broker fake. +- Assert no callback executes after cancellation, lease loss, or permit expiry. + +### Failure and adversarial tests + +- Oversized lines, embedded newlines, invalid UTF-8, malformed JSON-RPC, + unknown response IDs, stdout logs, stderr floods, and partial writes. +- Permission spoofing, terminal ID reuse, path traversal, symlink escape, + cross-session tool IDs, and duplicate JSON-RPC messages. +- Crash between permit issue and result persistence, with an explicit unknown + execution outcome instead of a false success. + +Full provider, ECS, and manual terminal tests are separate requested gates and +are not implied by this design. + +## 16. Open questions + +- Which signed/versioned distribution policy should supplement the MVP's fixed + installed `codex-acp` and `claude-agent-acp` executable profiles? +- Should unsupported `session/resume` fall back to `session/load` + automatically, or require a profile-level opt-in because replay cost varies? +- Which ACP optional updates are stored verbatim for future presenters without + expanding the stable Task schema? +- What output and lifetime limits should govern background ACP terminals? + +The SDK/toolchain question is resolved for this candidate: Rust 1.88 is the +minimum, SDK 2.0.0 is pinned, and the negotiated stable wire remains v1. + +## 17. Normative external references + +- [ACP architecture](https://agentclientprotocol.com/get-started/architecture) +- [ACP v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization) +- [ACP v1 session setup](https://agentclientprotocol.com/protocol/v1/session-setup) +- [ACP v1 prompt turn](https://agentclientprotocol.com/protocol/v1/prompt-turn) +- [ACP v1 tool calls and permission](https://agentclientprotocol.com/protocol/v1/tool-calls) +- [ACP v1 filesystem](https://agentclientprotocol.com/protocol/v1/file-system) +- [ACP v1 terminals](https://agentclientprotocol.com/protocol/v1/terminals) +- [ACP v1 cancellation](https://agentclientprotocol.com/protocol/v1/cancellation) +- [ACP v1 transports](https://agentclientprotocol.com/protocol/v1/transports) +- [Official ACP Rust SDK](https://agentclientprotocol.com/libraries/rust) +- [Current SDK 2.0.0 manifest](https://docs.rs/crate/agent-client-protocol/2.0.0/source/Cargo.toml) +- [ACP protocol repository versioning](https://github.com/agentclientprotocol/agent-client-protocol#versioning) diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design_zh.md new file mode 100644 index 0000000000..46d52c6ff4 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/acp-client-bridge/design_zh.md @@ -0,0 +1,401 @@ +# Phase 2 ACP Client Bridge 设计 + +[English](design.md) + +状态:未提交候选工作树已有第一轮实现切片;固定基线 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 不包含该实现,且 production bridge 尚未通过验收。 + +相关文档:[阶段规划](../../README_zh.md)、[验收报告](acceptance_zh.md)以及更窄的 +[Local ACP Runtime MVP](../../phase-1/acp-mvp/design_zh.md)。 + +## 1. 决策 + +ACP Client Bridge 让 COSH 通过中立的 `AgentRuntimePort` 成为 ACP Client。 +它启动本地 Agent 子进程,在 stdio 上使用 newline-delimited JSON-RPC +通信,并把 Agent 生命周期转换成 COSH Task event。Bridge 不拥有 Task、 +approval、OS policy、Shell PTY 或 Web delivery。 + +版本契约包含两个相互独立的维度: + +| 维度 | Phase 2 决策 | +| --- | --- | +| ACP wire protocol | v1;发送 `initialize.protocolVersion = 1` | +| Rust SDK | 准确固定官方 `agent-client-protocol = 2.0.0`;cosh-ng MSRV 与 RPM build baseline 为 Rust 1.88 | +| Capability 演进 | 初始化时协商;省略即不支持 | +| Transport | 只支持本地 subprocess stdio | +| Streamable HTTP | 不作为 Phase 2 依赖;该 transport 仍是 draft proposal | +| ACP v2 | 不在范围内 | + +SDK package 版本不是 ACP wire 版本。代码和配置均不得从 crate 或 schema +artifact 版本推断 wire compatibility。 + +## 2. 目标与非目标 + +### 目标 + +- 运行符合 ACP v1 的 Agent,同时避免其类型成为 COSH domain model。 +- Agent 进程重启前后保持持久 `TaskId` 和 `RunId`。 +- ACP session 只能映射到 `AgentSessionId` binding。 +- 把 Agent message、plan、tool call、usage 和 terminal reference 转换成有序 + Runtime event。 +- 每个 permission、filesystem 和 terminal callback 都必须经过 COSH + Capability Broker 与 Approval Service。 +- 无法证明 version、capability、identity 或 callback scope 时 fail closed。 +- 让现有 cosh-core bridge 继续作为独立 Runtime Adapter 使用。 + +### 非目标 + +- 把 ACP 用作 Shell、Web、钉钉或飞书的 Gateway API。 +- 让 ACP connection 或 session 成为持久 Task 的事实来源。 +- 首版支持远端 ACP transport、ACP v2 或自定义 ACP extension。 +- 让 ACP Agent 直接访问宿主 PTY 或 filesystem。 +- 原地转换 cosh-core 内部 JSONL protocol。它继续使用独立 bridge。 +- 当 Agent 没有声明兼容的 load 或 resume capability 时保证进程无感恢复。 + +### 已实现的第一轮边界 + +候选工作树在 `cosh-gateway::runtime` 下增加同步 `AcpV1Codec` 与 +`AcpV1RuntimeBridge`。官方 SDK v1 类型负责校验 JSON-RPC frame,runtime-local projection +阻止 SDK 类型进入 `cosh-gateway-contracts`。Bridge 组合一个 `RuntimeSupervisor`,后者继续是 +唯一 child-process lifecycle implementation, +保留 direct launch、cleared environment、pinned cwd、bounded stdout/stderr 与 +process-group reap。 + +当前切片实现 exact v1 initialization、immutable capability copy、单一 opaque session、 +text prompt、已校验 `session/update`、prompt terminal response、correlated permission +response、cancel settlement、unsupported callback rejection 与 bounded fail-closed decode。 +它尚未把 ACP observation 映射到持久 Runtime/Task event,也未把 filesystem、terminal +和 permission operation 接到 production Broker/Approval service。 + +候选实现还提供固定的内置 profile,只支持已安装的 `codex-acp` 与 +`claude-agent-acp` adapter。Resolver 只接受准确的 profile executable,canonicalize executable +与 workspace,只复制 allowlisted environment variable,并且不会调用 shell、package runner、 +download 或 network bootstrap。它仍是 library API;有界 Session Driver 已调用它,但尚无已安装 +COSH entrypoint。 + +## 3. 当前源码证据 + +固定基线已有可复用的 Adapter 和生命周期代码,但不存在 ACP 实现。候选工作树另外包含 +上述有界第一轮切片,并在 `Cargo.lock` 中固定官方 SDK。 + +| 证据 | 已有能力 | 与本设计相关的缺口 | +| --- | --- | --- | +| [`AgentAdapter`](../../../../../crates/cosh-shell/src/adapter/mod.rs) | Provider 中立的名称、capability、同步 run 和流式 event callback | owner 是 `cosh-shell`,request type 包含 Shell command block | +| [`AgentRequest` 和 `AgentEvent`](../../../../../crates/cosh-shell/src/types/mod.rs) | Run ID、text delta、tool event、question、approval、completion 和 failure | ID 与 event 是进程内 Shell 类型,不是持久 Task 契约 | +| [`CoshCoreAdapter`](../../../../../crates/cosh-shell/src/adapter/cosh_core.rs) | 持久 cosh-core 子进程 Adapter 和 provider-session recovery | 该 Adapter 不是 ACP,且生命周期与 Shell owner 的状态耦合 | +| [`protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | 内部 JSONL 初始化、streaming、approval、question、cancellation 和 result message | `CONTROL_PROTOCOL_VERSION = 1` 是 COSH 私有协议,不是 ACP v1 | +| [`headless.rs`](../../../../../crates/cosh-core/src/headless.rs) | 严格的内部 protocol negotiation 与 workspace-scoped provider session persistence | 不经过独立 Adapter 契约就不能作为 ACP 暴露 | +| [`session.rs`](../../../../../crates/cosh-core/src/session.rs) | `ProviderSessionId` 与有版本的 provider conversation persistence | Provider session 不是 Task、Run 或 ACP session identity | + +基线 commit 上没有 ACP SDK dependency、ACP schema、`initialize` request、ACP +JSON-RPC router 或 ACP conformance fixture。 + +## 4. Ownership 与 Port + +```mermaid +flowchart LR + TC["TaskCoordinator"] --> ARP["AgentRuntimePort"] + ARP --> ACB["AcpClientBridge"] + ACB --> SUP["RuntimeSupervisor"] + SUP --> PROC["ACP Agent subprocess"] + PROC <--> STDIO["ACP v1 JSON-RPC / stdio"] + STDIO <--> ACB + ACB --> EV["RuntimeEventSink"] + ACB --> CB["Client callback router"] + CB --> BROKER["CapabilityBrokerPort"] + CB --> APPROVAL["ApprovalPort"] + BROKER --> TARGET["ExecutionTargetPort"] +``` + +### Bridge 拥有的状态 + +- ACP process handle、stderr capture 与有界 stdout decoder。 +- JSON-RPC request router 和未完成 request 的 cancellation handle。 +- 协商后的 protocol version 和不可变 connection capability。 +- `AgentSessionId` 到不透明 ACP `sessionId` 的 binding。 +- 以 session 为 scope 的 ACP message、tool-call 和 terminal correlation table。 +- 临时 flow-control 状态,以及最后交给 Task Plane 的 event sequence。 + +### 由其他模块拥有的状态 + +| 状态 | Owner | +| --- | --- | +| Task lifecycle、Run attempt、replay cursor | Task Execution Plane | +| Actor、channel 和 target identity | Gateway 与 identity 模块 | +| Approval request 和 decision | Approval Service | +| OS authorization 与 permit | Capability Broker | +| OS process 或 typed operation | Execution Target | +| ACP subprocess restart policy | Runtime Supervisor | +| 用户可见 rendering 与 delivery | Presentation Adapter | + +Bridge 接收 typed command 并产生 typed event。它不得 import HTTP request type、 +Terminal card model、persistence record 或 channel message type。 + +## 5. Runtime Command 契约 + +Phase 2 Bridge 实现 Phase 0 和 Phase 1 建立的中立 Runtime Port。概念上的 +command 如下: + +```text +StartSession { task_id, run_id, workspace, additional_roots, runtime_profile } +ResumeSession { task_id, run_id, agent_session_id } +Prompt { task_id, run_id, agent_session_id, content, idempotency_key } +CancelRun { task_id, run_id, reason } +CloseSession { task_id, agent_session_id, reason } +TerminateRuntime { runtime_instance_id, reason } +``` + +`StartSession` 返回 COSH 创建的 `AgentSessionId` 以及不透明 Runtime binding。 +ACP `sessionId` 保存在该 binding 内部,绝不作为 `TaskId` 或 `RunId` 返回。 + +## 6. ACP Profile 与 Capability Policy + +### 初始化 + +Bridge 首先发送 `initialize`,其中包含: + +- `protocolVersion: 1`; +- COSH Client implementation information; +- 仅包含已经由 COSH 验收实现支撑的 Client capability。 + +如果响应选择的 protocol version 不是 `1`,Bridge 必须拒绝。Capability 会 +复制到不可变 connection snapshot。缺失的 optional capability 表示不支持, +不能把它当作意外的 false,也不能通过试探性调用发现。 + +首版 profile 要求 ACP v1 baseline session operation:`session/new`、 +`session/prompt`、`session/cancel` 和 `session/update`。`session/load`、 +`session/resume`、`session/close`、`session/list`、`session/delete`、 +additional directory、config option 和 rich prompt content 等 optional method +仅在对端声明后调用。 + +### Client Capability 声明 + +首个 production profile 应从最小集合开始: + +| Capability | 允许声明的条件 | +| --- | --- | +| `fs.readTextFile` | Read request 能被限定 scope、授权、限流、审计并由 Broker 路径处理 | +| `fs.writeTextFile` | Write 能取得 target-bound permit 并产生持久 audit evidence | +| `terminal` | 所有 terminal method 已通过 governed execution handle 实现 | +| rich prompt content | Task schema 和 Presenter 能无损保留该 content | +| elicitation/config option | Gateway command 和所有已 attach Presenter 都能确定性处理 | + +不能因为官方 SDK 中存在某种类型就声明相应 capability。 + +## 7. Identity 与 Correlation 映射 + +| ACP field 或对象 | COSH 映射 | Invariant | +| --- | --- | --- | +| ACP connection | `RuntimeInstanceId` | 临时身份;一个 connection 可包含多个 session | +| ACP `sessionId` | `AgentSessionBinding` 内部的不透明值 | 只映射一个 `AgentSessionId` | +| JSON-RPC request `id` | `RuntimeRequestId` | Scope 是一个 connection;不是全局持久 identity | +| `session/prompt` request | `RunId` 的一个 active Runtime turn | Retry 需要 COSH idempotency key;ACP 本身不保证 prompt 幂等 | +| ACP `messageId` | 带 session scope 的 `RuntimeMessageId` | 用于组合 chunk;不能成为 event sequence | +| ACP `toolCallId` | `ToolUseId` | 在绑定的 Agent session 内稳定 | +| ACP `terminalId` | 与 Broker 创建的 `ExecutionId` 绑定的不透明 handle | 在 Agent session 和 target permit 外无效 | +| Permission option ID | COSH `ApprovalRequest` 的 option | Agent 提供的 label 是显示数据,不是 authorization policy | + +Callback 带有未知 session、tool call、terminal 或已经完成的 request correlation +时,Bridge 必须拒绝。 + +## 8. 生命周期、Detach 与 Replay + +```mermaid +stateDiagram-v2 + [*] --> Starting + Starting --> Initialized: initialize v1 accepted + Starting --> Failed: spawn or negotiation failure + Initialized --> Ready: session/new or supported resume + Ready --> Prompting: session/prompt + Prompting --> AwaitingDecision: permission or elicitation request + AwaitingDecision --> Prompting: response delivered + Prompting --> Ready: StopReason received + Prompting --> Cancelling: cancel command + Cancelling --> Ready: prompt request settles + Ready --> Detached: no presentation attachment + Detached --> Ready: presentation reattaches + Ready --> Closing: close or supervisor shutdown + Closing --> Closed + Failed --> Starting: supervisor creates a new runtime attempt +``` + +Presentation detach 不产生 ACP wire 操作。它只移除 Shell 或 Web subscription, +Task 继续作为权威状态。只有显式生命周期决策才会发送 `session/close`,且需要 +对端声明该 capability。 + +Bridge 或 Agent 重启后的处理: + +1. Task Plane 创建新的 Runtime attempt,但不改变 `TaskId`。 +2. Bridge 初始化新的 ACP connection。 +3. 对端声明且兼容时,使用不重放历史的 `session/resume`。 +4. 否则可使用 `session/load`,其 `session/update` 历史标记为 replay。 +5. 两者都不可用时,Run 进入可恢复 blocked 状态,并要求用户明确选择 fresh + session。Bridge 不得静默重发已完成或部分执行的 prompt。 + +ACP replay update 在归一化后以新的 COSH event sequence 追加。有 `messageId` +时,使用带 scope 的 message ID 和 content offset 抑制重复 chunk。持久 event +sequence 始终是唯一的 Presentation replay cursor。 + +## 9. Event 映射 + +| ACP 输入 | Runtime/Task event | +| --- | --- | +| Agent message chunk | `AgentMessageChunkRecorded` | +| Load 期间的 user message chunk | `AgentHistoryChunkReplayed` | +| Thought chunk | 带 redaction/presentation policy 的 `AgentThoughtChunkRecorded` | +| Plan | `AgentPlanReplaced` | +| Tool call | `ToolUseDeclared` | +| Tool call update | `ToolUseUpdated` | +| Usage update | `AgentUsageUpdated` | +| Session info update | `AgentSessionMetadataUpdated` | +| Permission request | Policy 归一化后的 `ApprovalRequested` | +| Prompt StopReason | 带归一化 reason 的 `RuntimeTurnFinished` | +| JSON-RPC error 或 process exit | 带 retry 分类的 `RuntimeAttemptFailed` | + +实现前由 Phase 0 schema 冻结准确 serialized name。未知 ACP update 作为有界 +diagnostic metadata 保留并产生 compatibility event,不能显示为成功 tool +execution。 + +## 10. Permission、Filesystem 与 Terminal Callback + +### Permission + +`session/request_permission` 创建或关联 COSH approval。Approval Service 在 +发送响应前评估 actor、target、Task state、operation detail 与 policy。只有 +COSH 支持相应持久 policy scope 时才可提供 `allow_always`;Agent option 本身 +不能创建范围更宽的 trust rule。取消 prompt 时,以 ACP `cancelled` outcome +结算未完成的 ACP permission request。 + +### Filesystem + +`fs/read_text_file` 和 `fs/write_text_file` 转换成 typed Broker request。 +Absolute path 依据 session workspace 和已接受的 additional root 归一化。 +Symlink、traversal、size、encoding、redaction 和 write-conflict policy 在 +Bridge 下层执行。Bridge 绝不直接打开 Agent 请求的路径。 + +### Terminal + +`terminal/create`、`output`、`wait_for_exit`、`kill` 和 `release` 映射到 +Broker 评估后由 Execution Target 签发的 handle。它们不 attach 到用户交互式 +cosh-shell PTY。Output 必须在合法 UTF-8 边界限流,受到审计,并按 Task policy +保留。Release 幂等;关闭 session 时释放所有遗留 execution handle。 + +## 11. 安全与 Approval Invariant + +- ACP 的 trusted-editor 设计假设不是 OS 安全边界。 +- 每个 callback 必须限定在初始化 connection、绑定的 Agent session、Task、 + actor、target 与 workspace 内。 +- ACP Agent metadata、tool kind、title、raw input 和 permission option 都是不可信 + display data。 +- Environment variable 和 command argument 在写入 diagnostic persistence 前必须 + redaction。 +- Stdout 只接受合法且有界的 ACP JSON-RPC message;malformed 或非 protocol + output 会终止 Runtime attempt。 +- Stderr 仅用于诊断,必须限流和 redaction,且永不按 ACP 解析。 +- 即使在 trust mode,filesystem 或 terminal callback 也不能绕过 Broker。 +- Permit 绑定 operation digest、target、actor、Task 和 expiry,不能复用于其他 + ACP callback。 + +## 12. Error、Backpressure 与弱网 + +首版 transport 是本地 stdio,因此 ACP hop 不涉及网络重连。弱网仍可能影响 +Agent 或 Broker 后方的 model provider 与远端 Execution Target。 + +| 故障 | 必须的行为 | +| --- | --- | +| Agent executable 不存在 | 在绑定 session 前令 Runtime attempt 失败 | +| 初始化超时或版本错误 | 终止进程并报告不可重试 compatibility failure | +| 非法 JSON-RPC 或 stdout 污染 | Fail closed,并保留有界 diagnostic evidence | +| Prompt 中 Agent process exit | 将 attempt 标记为失败;不推断副作用是否完成 | +| Agent 报告 provider/network loss | 保留 Task 和 Run;只根据结构化 failure evidence 分类 retry | +| Task event sink 过慢 | 使用有界 backpressure;在无限缓冲前 cancel 并失败 | +| Client callback 超时 | 返回 ACP error 或 cancelled outcome,并记录 Broker/Approval timeout | +| 重复 callback 或 response | 通过 idempotency/correlation state 处理;绝不执行两次 | +| Task daemon 重启 | 从 Task state 重建 binding,再 resume/load 或请求 fresh session 决策 | + +## 13. 迁移与兼容 + +1. Phase 0 冻结 Runtime Port command、event、ID 和 fixture。 +2. Phase 1 期间继续让 `CoshCoreBridge` 作为默认 Agent Runtime。 +3. ACP SDK 只加入 ACP owner 的 crate 或 module;不得把 ACP type 加入 Gateway 或 + Task public schema。 +4. 启用外部 executable 前,先实现内存 fake Agent 和 conformance fixture。 +5. 通过显式 Runtime 配置和 registry metadata 启用 ACP profile。 +6. 回滚方式是禁用 ACP Runtime profile。现有 cosh-core 和 direct Shell 路径 + 保持不变。 + +持久 binding 包含 schema version 和 runtime kind,但不序列化 SDK struct。SDK +minor update 必须继续通过同一套 wire fixture;即使 ACP wire v1 不变,SDK major +变化也需要显式 compatibility review。 + +## 14. 实现任务 + +| Work item | Owner | 依赖 | +| --- | --- | --- | +| ACP subprocess supervisor 和有界 stdio channel | `RuntimeSupervisor` + `AcpClientBridge` | Phase 0 supervision ADR | +| Version 与 capability negotiation | `AcpClientBridge` | Protocol contract fixture | +| Session binding repository adapter | `AcpClientBridge` + Task Plane | Identity 和 persistence schema | +| Prompt/update normalizer | `AcpClientBridge` | Runtime event schema | +| Permission callback adapter | `AcpClientBridge` + Approval | Approval contract | +| Filesystem callback adapter | `AcpClientBridge` + Broker | Capability request 和 target scope | +| Terminal callback adapter | `AcpClientBridge` + Execution Target | Governed execution handle contract | +| Cancellation 与 shutdown settlement | `RuntimeSupervisor` + `AcpClientBridge` | Runtime lease 和 Run state machine | +| Compatibility 与 conformance suite | `AcpClientBridge` | 官方 ACP v1 schema/SDK fixture | +| Operator diagnostic | Presentation | Redaction 与 error taxonomy | + +## 15. 测试策略 + +### Contract test + +- 验证 `protocolVersion: 1`、准确版本拒绝和 capability omission。 +- 使用官方 ACP v1 schema 验证每种已支持 message。 +- 证明选定 SDK artifact 及后续升级不会改变已接受的 ACP v1 wire fixture。 +- 验证所有 ID mapping 都拒绝跨 session 和跨 Task 混淆。 + +### Integration test + +- 通过 stdio 启动确定性 fake ACP Agent。 +- 覆盖 new、prompt、streaming、permission、cancellation、close、process crash、 + load replay 和 resume-without-replay。 +- 断言 filesystem 和 terminal request 只到达 Broker fake。 +- 断言 cancellation、lease loss 或 permit expiry 后没有 callback 执行。 + +### Failure 与 adversarial test + +- Oversized line、embedded newline、非法 UTF-8、malformed JSON-RPC、未知 response + ID、stdout log、stderr flood 和 partial write。 +- Permission spoofing、terminal ID reuse、path traversal、symlink escape、跨 session + tool ID 和重复 JSON-RPC message。 +- Permit 签发与 result 持久化之间 crash 时,必须报告未知 execution outcome, + 不能产生虚假成功。 + +完整 provider、ECS 和手工 Terminal 测试需要单独明确请求,本设计不隐含这些 +gate 已执行。 + +## 16. 开放问题 + +- 哪种 signed/versioned distribution policy 应补充 MVP 固定的已安装 `codex-acp` 与 + `claude-agent-acp` executable profile? +- 不支持 `session/resume` 时,是否自动回退到 `session/load`,还是考虑 replay + 成本差异而要求 profile 显式开启? +- 哪些 ACP optional update 应原样存储以支持未来 Presenter,同时避免扩大稳定 + Task schema? +- Background ACP terminal 应采用怎样的 output 与 lifetime 限制? + +SDK/toolchain 问题已在当前候选中解决:最低 Rust 为 1.88,准确固定 SDK 2.0.0, +协商的稳定 wire 仍为 v1。 + +## 17. 规范性外部资料 + +- [ACP 架构](https://agentclientprotocol.com/get-started/architecture) +- [ACP v1 初始化](https://agentclientprotocol.com/protocol/v1/initialization) +- [ACP v1 Session Setup](https://agentclientprotocol.com/protocol/v1/session-setup) +- [ACP v1 Prompt Turn](https://agentclientprotocol.com/protocol/v1/prompt-turn) +- [ACP v1 Tool Call 与 Permission](https://agentclientprotocol.com/protocol/v1/tool-calls) +- [ACP v1 Filesystem](https://agentclientprotocol.com/protocol/v1/file-system) +- [ACP v1 Terminal](https://agentclientprotocol.com/protocol/v1/terminals) +- [ACP v1 Cancellation](https://agentclientprotocol.com/protocol/v1/cancellation) +- [ACP v1 Transport](https://agentclientprotocol.com/protocol/v1/transports) +- [官方 ACP Rust SDK](https://agentclientprotocol.com/libraries/rust) +- [当前 SDK 2.0.0 manifest](https://docs.rs/crate/agent-client-protocol/2.0.0/source/Cargo.toml) +- [ACP protocol 仓库版本说明](https://github.com/agentclientprotocol/agent-client-protocol#versioning) diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance.md new file mode 100644 index 0000000000..c2a2e09433 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance.md @@ -0,0 +1,121 @@ +# Phase 2 Shell Attachment Acceptance Report + +[中文版](acceptance_zh.md) + +Related design: [Shell Attachment design](design.md). + +## 1. Report scope + +- Baseline reviewed: `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Review date: 2026-08-12 +- Change type: planning documentation only +- Implementation acceptance: **NOT ACCEPTED** + +This is a readiness baseline and future exit gate. No Task attachment behavior +was implemented or live-tested by this documentation change. + +## 2. Baseline evidence + +The baseline shell owns its PTY and foreground child, preserves native +bash/zsh behavior, routes raw input/output, renders inline cards, and stores +substantial runtime state in `InlineState`. It has a provider session adapter +and a redacted shell event journal. + +It does not have a Gateway client, durable Task attachment, projection replay, +delivery cursor, interaction lease, or Task-governed foreground PTY target. +The shell event journal is not a Task EventStore. + +## 3. Current readiness + +| Area | Baseline status | Acceptance status | Evidence needed to pass | +| --- | --- | --- | --- | +| Foreground PTY ownership | Implemented in shell host | Existing behavior only | Regression evidence under attached/degraded modes | +| Direct local shell mode | Implemented | Existing behavior only | Gateway outage and rollback tests | +| Gateway attachment client | Not present | **NOT IMPLEMENTED** | Versioned API contract and integration tests | +| Durable attachment identity | Not present | **NOT IMPLEMENTED** | `AttachmentId` and lease persistence tests | +| Task projection presenter | Terminal renderer is shell-state driven | **NOT IMPLEMENTED** | Golden projection-to-card fixtures | +| Replay cursor | Shell event cursor is in memory | **NOT IMPLEMENTED** | Restart/reconnect cursor tests | +| Attach/detach lifecycle | Not present | **NOT IMPLEMENTED** | State-machine integration tests | +| Interaction lease | Not present | **NOT IMPLEMENTED** | Multi-client claim/expiry tests | +| Gateway prompt/cancel path | Provider calls are shell-owned | **NOT IMPLEMENTED** | Idempotent Task command tests | +| Gateway approval/question path | Local card state is authoritative | **NOT IMPLEMENTED** | Versioned command/replay tests | +| Governed foreground PTY target | Approved handoff is local shell logic | **NOT IMPLEMENTED** | Broker permit and target lifecycle tests | +| Task/shell ID separation | Types are not enforced across processes | **NOT IMPLEMENTED** | Cross-ID negative tests | +| Daemon restart recovery | Not present | **NOT IMPLEMENTED** | PTY-continuity and projection-replay tests | + +Existing PTY and direct-mode behavior must be preserved, but it does not by +itself satisfy Phase 2 attachment acceptance. + +## 4. Exit criteria + +| ID | Criterion | Required proof | +| --- | --- | --- | +| SH-01 | `ShellSessionId`, `TaskId`, and `AgentSessionId` are distinct types and lifecycles | API review and cross-ID compile/negative tests | +| SH-02 | cosh-shell remains the sole owner of the foreground PTY descriptors | Ownership review and process-lifecycle tests | +| SH-03 | Direct local commands work when Gateway is down | Raw shell integration and manual TTY evidence | +| SH-04 | Gateway failure never blocks the PTY input/output relay | Disconnect and saturation tests | +| SH-05 | Attach returns snapshot, replay, and a stable cursor | Gateway fake integration test | +| SH-06 | Detach stops presentation only and does not cancel Task, Run, Agent session, or PTY | Lifecycle test for every non-effect | +| SH-07 | Shell exit releases attachment while a durable Task continues | Process-exit integration test | +| SH-08 | Reconnect applies each projection item once from the last contiguous cursor | Restart and duplicate-delivery test | +| SH-09 | Expired cursor rebuilds cards from a snapshot without replaying side effects | Retention-gap test | +| SH-10 | Approval and question decisions are authoritative only after Gateway acceptance | Lost-response, stale-version, and replay tests | +| SH-11 | Concurrent interactive clients are bounded by an expiring lease | Shell/Web contention test | +| SH-12 | Agent commands reach the foreground PTY only with a target-bound Broker permit | Permit forgery, expiry, and digest mismatch tests | +| SH-13 | ACP terminals do not implicitly attach to the foreground PTY | Runtime-to-target routing negative test | +| SH-14 | PTY busy, timeout, disconnect, and owner exit produce typed non-success outcomes | Target lifecycle tests | +| SH-15 | Untrusted projection content cannot inject terminal control sequences | Rendering adversarial fixtures | +| SH-16 | Existing job control, signals, resize, alternate screen, and terminal recovery remain intact | Shell host suite plus requested manual TTY gate | +| SH-17 | Disabling attachments restores the current direct/cosh-core paths | Rollback smoke test | + +All criteria are mandatory for the Shell Attachment module exit. + +## 5. Required automated evidence + +The implementation report must include the full candidate SHA, exact commands, +test counts, and failures or skips. At minimum it must cover the repository's +closest shell layers: + +```text +cargo test --package cosh-shell --lib +cargo test --package cosh-shell --test logic +cargo test --package cosh-shell --test protocol +cargo test --package cosh-shell --test shell_host -- --test-threads=4 +crates/cosh-shell/scripts/check-layout.sh +``` + +New targeted attachment tests may use another approved target, but they do not +replace the PTY regressions when `shell_host/` changes. These commands were not +run for this documentation-only report. + +## 6. Required manual evidence + +Before release, an explicitly requested manual TTY gate must verify: + +- direct bash/zsh command entry and interactive programs; +- attach, live update, detach, and reattach; +- Gateway stop/restart while the foreground shell stays usable; +- approval and question capture, including cancellation and stale decision; +- `Ctrl+C`, resize, alternate screen, foreground job, and terminal restoration; +- Agent-permitted PTY handoff with visible origin and result; +- shell exit while the Task continues in another presenter. + +The report must identify the exact commit and environment and sanitize any +workspace, command output, or credentials. No such test was performed here. + +## 7. Remaining blockers + +- Phase 1 Gateway API, Task projections, Outbox, command idempotency, and + Capability Broker must exist. +- Presentation and attachment schemas must be frozen in Phase 0. +- The interaction-lease default between Shell and Web is unresolved. +- Cursor cache retention and protection need an approved policy. +- Migration ownership for current `InlineState` fields needs a reviewed file + plan before implementation. + +## 8. Acceptance decision + +**NOT IMPLEMENTED / NOT ACCEPTED.** Existing PTY functionality is confirmed by +source inspection only. Phase 2 Shell Attachment acceptance requires SH-01 +through SH-17 evidence on one candidate revision, including the requested +manual TTY gate when implementation is ready. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance_zh.md new file mode 100644 index 0000000000..2230c63140 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/acceptance_zh.md @@ -0,0 +1,116 @@ +# Phase 2 Shell Attachment 验收报告 + +[English](acceptance.md) + +相关设计:[Shell Attachment 设计](design_zh.md)。 + +## 1. 报告范围 + +- 审计基线:`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 审计日期:2026-08-12 +- 变更类型:仅规划文档 +- 实现验收:**NOT ACCEPTED** + +这是当前 readiness baseline 和未来 exit gate。本次文档变更没有实现或在线 +测试任何 Task attachment 行为。 + +## 2. 基线证据 + +基线 Shell 拥有 PTY 与 foreground child,保持原生 bash/zsh 行为,转发 raw +input/output,渲染 inline card,并在 `InlineState` 保存大量 Runtime state。它 +还有 provider session Adapter 与脱敏的 Shell event journal。 + +基线没有 Gateway Client、持久 Task attachment、projection replay、delivery +cursor、interaction lease 或 Task-governed foreground PTY target。Shell event +journal 不是 Task EventStore。 + +## 3. 当前就绪度 + +| 领域 | 基线状态 | 验收状态 | 通过所需证据 | +| --- | --- | --- | --- | +| Foreground PTY ownership | 已在 Shell Host 实现 | 仅为现有行为 | Attached/degraded mode 下的 regression evidence | +| Direct local shell mode | 已实现 | 仅为现有行为 | Gateway outage 和 rollback test | +| Gateway attachment client | 不存在 | **NOT IMPLEMENTED** | Versioned API contract 和 integration test | +| 持久 attachment identity | 不存在 | **NOT IMPLEMENTED** | `AttachmentId` 与 lease persistence test | +| Task projection presenter | Terminal renderer 由 Shell state 驱动 | **NOT IMPLEMENTED** | Golden projection-to-card fixture | +| Replay cursor | Shell event cursor 只在内存中 | **NOT IMPLEMENTED** | Restart/reconnect cursor test | +| Attach/detach lifecycle | 不存在 | **NOT IMPLEMENTED** | State-machine integration test | +| Interaction lease | 不存在 | **NOT IMPLEMENTED** | Multi-client claim/expiry test | +| Gateway prompt/cancel path | Provider call 由 Shell 拥有 | **NOT IMPLEMENTED** | Idempotent Task command test | +| Gateway approval/question path | Local card state 具有权威性 | **NOT IMPLEMENTED** | Versioned command/replay test | +| Governed foreground PTY target | Approved handoff 是本地 Shell 逻辑 | **NOT IMPLEMENTED** | Broker permit 与 target lifecycle test | +| Task/Shell ID separation | 尚未跨进程类型化约束 | **NOT IMPLEMENTED** | Cross-ID negative test | +| Daemon restart recovery | 不存在 | **NOT IMPLEMENTED** | PTY-continuity 与 projection-replay test | + +必须保留现有 PTY 与 direct-mode 行为,但它们本身不满足 Phase 2 attachment +验收。 + +## 4. Exit Criteria + +| ID | 标准 | 必需证明 | +| --- | --- | --- | +| SH-01 | `ShellSessionId`、`TaskId` 与 `AgentSessionId` 具有独立类型和生命周期 | API review 与 cross-ID compile/negative test | +| SH-02 | cosh-shell 继续作为 foreground PTY descriptor 的唯一 owner | Ownership review 与 process-lifecycle test | +| SH-03 | Gateway 关闭时 direct local command 仍可运行 | Raw shell integration 和 manual TTY evidence | +| SH-04 | Gateway failure 永不阻塞 PTY input/output relay | Disconnect 与 saturation test | +| SH-05 | Attach 返回 snapshot、replay 与稳定 cursor | Gateway fake integration test | +| SH-06 | Detach 只停止 Presentation,不取消 Task、Run、Agent session 或 PTY | 对每个非效果的生命周期测试 | +| SH-07 | Shell 退出释放 attachment,而持久 Task 继续 | Process-exit integration test | +| SH-08 | 重连从最后连续 cursor 开始,对每个 projection item 只应用一次 | Restart 与 duplicate-delivery test | +| SH-09 | Cursor 过期时从 snapshot 重建 card,不 replay side effect | Retention-gap test | +| SH-10 | Approval 与 question decision 只有在 Gateway 接受后才有权威性 | Lost-response、stale-version 与 replay test | +| SH-11 | 并发 interactive Client 由带 expiry 的 lease 限制 | Shell/Web contention test | +| SH-12 | Agent command 只有具备 target-bound Broker permit 才能进入 foreground PTY | Permit forgery、expiry 与 digest mismatch test | +| SH-13 | ACP terminal 不会隐式 attach 到 foreground PTY | Runtime-to-target routing negative test | +| SH-14 | PTY busy、timeout、disconnect 和 owner exit 产生 typed non-success outcome | Target lifecycle test | +| SH-15 | 不可信 projection content 不能注入 Terminal control sequence | Rendering adversarial fixture | +| SH-16 | 现有 job control、signal、resize、alternate screen 和 terminal recovery 保持不变 | Shell Host suite 加明确请求的 manual TTY gate | +| SH-17 | 禁用 attachment 可恢复当前 direct/cosh-core 路径 | Rollback smoke test | + +Shell Attachment 模块退出时必须满足所有标准。 + +## 5. 必需自动化证据 + +实现报告必须包含 candidate 完整 SHA、准确 command、test count 和 failure/skip。 +至少覆盖仓库最接近的 Shell 分层: + +```text +cargo test --package cosh-shell --lib +cargo test --package cosh-shell --test logic +cargo test --package cosh-shell --test protocol +cargo test --package cosh-shell --test shell_host -- --test-threads=4 +crates/cosh-shell/scripts/check-layout.sh +``` + +新增 targeted attachment test 可以使用其他已批准 target,但修改 `shell_host/` +时不能替代 PTY regression。本次仅文档报告没有运行上述命令。 + +## 6. 必需手工证据 + +Release 前必须明确请求 manual TTY gate,并验证: + +- Direct bash/zsh command entry 与 interactive program; +- Attach、live update、detach 和 reattach; +- Gateway stop/restart 时 foreground shell 仍可使用; +- Approval 与 question capture,包括 cancellation 和 stale decision; +- `Ctrl+C`、resize、alternate screen、foreground job 和 Terminal restoration; +- 带可见 origin 和 result 的 Agent-permitted PTY handoff; +- Shell 退出后 Task 在其他 Presenter 中继续。 + +报告必须标识准确 commit 与环境,并脱敏 workspace、command output 和 credential。 +本次没有执行该测试。 + +## 7. 剩余 Blocker + +- 必须具备 Phase 1 Gateway API、Task projection、Outbox、command idempotency 与 + Capability Broker。 +- 必须在 Phase 0 冻结 Presentation 与 attachment schema。 +- Shell 与 Web 的 interaction-lease 默认归属尚未决定。 +- Cursor cache retention 与保护需要批准 policy。 +- 实现前需要为当前 `InlineState` field 的迁移 ownership 制定并评审文件计划。 + +## 8. 验收决定 + +**NOT IMPLEMENTED / NOT ACCEPTED。** 当前仅通过源码检查确认现有 PTY 功能。 +Phase 2 Shell Attachment 验收要求在同一 candidate revision 上提供 SH-01 至 +SH-17 全部证据,并在实现就绪时完成明确请求的 manual TTY gate。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design.md new file mode 100644 index 0000000000..036f87d02e --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design.md @@ -0,0 +1,400 @@ +# Phase 2 Shell Attachment Design + +[中文版](design_zh.md) + +Status: planned, not implemented on baseline +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. + +Related documents: [phase plan](../../README.md) and +[acceptance report](acceptance.md). + +## 1. Decision + +cosh-shell remains the owner of the user's foreground PTY, terminal mode, job +control, and direct local shell path. Phase 2 adds a Task attachment beside +that path. The attachment consumes Gateway projections and submits Task +commands; it does not move PTY ownership into the Task daemon or ACP bridge. + +This separates three lifecycles that are currently co-located in one process: + +```text +ShellSessionId foreground bash/zsh and PTY lifetime +TaskId durable user intent and governance lifetime +AgentSessionId one Agent runtime conversation binding +``` + +Attach and detach operate on presentation membership. They never imply Agent +session close, Task cancel, or foreground shell termination. + +## 2. Goals and non-goals + +### Goals + +- Preserve native bash/zsh behavior, foreground process groups, signals, + terminal resize, history, and user takeover. +- Let one shell attach to a durable Task, replay from a cursor, approve, + answer, cancel, and detach. +- Keep interactive PTY output distinct from durable Task events. +- Continue running user-entered shell commands when the local Gateway or Agent + runtime is unavailable. +- Route Agent-requested execution through the Capability Broker while keeping + the existing approved foreground-PTY handoff as one Execution Target. +- Make shell rendering a presenter over stable projections instead of the + owner of Task state. + +### Non-goals + +- Moving the interactive PTY master into a daemon in Phase 2. +- Letting Web or ACP peers write directly to the user's foreground PTY. +- Making every direct user shell command a Task action. +- Persisting the terminal screen buffer as the canonical Task transcript. +- Supporting concurrent keystroke control from multiple Shell/Web clients. +- Removing existing adapters, inline cards, native shell mode, or non-AI + passthrough paths during migration. + +## 3. Current source evidence + +| Evidence | Current behavior | Phase 2 implication | +| --- | --- | --- | +| [`shell_host/bootstrap.rs`](../../../../../crates/cosh-shell/src/shell_host/bootstrap.rs) | `PtySession` owns master/slave files, child process, parser, and recovery files; bash/zsh become session leaders with a controlling TTY | PTY lifetime and file descriptors remain shell-process owned | +| [`shell_host/raw_runner.rs`](../../../../../crates/cosh-shell/src/shell_host/raw_runner.rs) | Relays input/output, tracks process groups, terminal size, prompt gate, and child exit | Attachment must not block or replace the relay loop | +| [`shell_host/model.rs`](../../../../../crates/cosh-shell/src/shell_host/model.rs) | `ShellHostConfig` defaults to native mode and can disable AI classification | Direct local shell remains an explicit compatibility boundary | +| [`runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) | `InlineState` holds approvals, questions, Agent runs, event cursor, cards, shell blocks, and session IDs in memory | Durable Task state must move behind the Gateway; `InlineState` becomes view/cache state | +| [`runtime/dispatcher.rs`](../../../../../crates/cosh-shell/src/runtime/dispatcher.rs) | A shell event snapshot drives inline actions and rendering | Presenter can reuse rendering concepts but not the shell-event domain as Task schema | +| [`runtime/controller.rs`](../../../../../crates/cosh-shell/src/runtime/controller.rs) | Inline rendering also emits approved handoffs to the PTY and captures card input | Task commands and PTY actions need separate ports and explicit correlation | +| [`shell_host/lifecycle.rs`](../../../../../crates/cosh-shell/src/shell_host/lifecycle.rs) | Shell events are redacted and written to `events.jsonl` when the host finishes | This journal is shell evidence, not a durable Task EventStore or Outbox | +| [`adapter/cosh_core.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core.rs) | Provider session state and recovery live behind the shell adapter | Phase 2 moves Agent runtime ownership behind `AgentRuntimePort` | + +The baseline has no Gateway client, Task attachment record, durable replay +cursor, cross-process presenter lease, or attach/detach API. + +## 4. Ownership model + +```mermaid +flowchart TB + USER["User keyboard/display"] <--> HOST["ShellHost / PTY owner"] + HOST <--> CHILD["Foreground bash/zsh + jobs"] + + HOST --> OBS["Shell evidence observer"] + OBS --> ATTACH["ShellAttachmentController"] + ATTACH <--> GW["Gateway API"] + GW <--> TASK["TaskCoordinator + Projection"] + + TASK --> BROKER["CapabilityBroker"] + BROKER --> TARGET["ForegroundPtyExecutionTarget"] + TARGET --> ATTACH + ATTACH --> HOST + + TASK --> EVENTS["Projection event stream"] + EVENTS --> PRES["ShellPresenter"] + PRES --> HOST +``` + +### Shell-owned + +- PTY master/slave descriptors and foreground child process. +- Terminal raw/cooked mode recovery, resize, signal, and job-control behavior. +- Keystroke capture and local card focus. +- Direct user command input and terminal output. +- A bounded local view cache and last acknowledged Task cursor. + +### Gateway/Task-owned + +- Task and Run state, Agent binding, approvals, questions, execution records, + projection sequence, and replay. +- Attachment membership and optional single-writer interaction lease. +- Idempotency and authorization of commands submitted by the shell. + +### Broker/Execution Target-owned + +- Authorization and permit for Agent-requested shell execution. +- Operation digest, target binding, timeout, result, and unknown-outcome state. + +The shell is authoritative only for facts observable from its PTY. It must not +declare a Task action complete before the Task plane commits the corresponding +execution result. + +## 5. Shell attachment port + +The shell consumes the same versioned Gateway API as other clients. A local +in-process optimization may exist later, but it must obey the same schema and +authorization checks. + +Conceptual commands: + +```text +AttachTask { + task_id, + shell_session_id, + actor_id, + after_cursor, + capabilities, + client_instance_id +} + +DetachTask { task_id, attachment_id, last_applied_cursor, reason } +SubmitPrompt { task_id, expected_version, content, idempotency_key } +ResolveApproval { task_id, approval_id, decision, expected_version, idempotency_key } +AnswerQuestion { task_id, question_id, answer, expected_version, idempotency_key } +CancelRun { task_id, run_id, reason, idempotency_key } +ClaimInteraction { task_id, attachment_id, ttl } +RenewInteraction { task_id, attachment_id, lease_token } +``` + +`AttachTask` returns an `AttachmentId`, current projection version, an ordered +replay page, and a next cursor. `after_cursor` is a Task event cursor, never a +shell OSC index or ACP message ID. + +## 6. Presentation schema + +The Shell Presenter maps stable projection items to existing or new card +models. The mapping is one-way: + +| Projection item | Shell surface | +| --- | --- | +| Task/Run status | Status or notice panel | +| Agent message chunk | Markdown stream card | +| Plan update | Plan/activity panel | +| Tool declaration/update | Tool invocation row | +| Approval pending/resolved | Approval panel and receipt | +| User question | Question panel | +| Execution output reference | Bounded detail view, fetched on demand | +| Runtime failure/recovery | Recoverable error notice | +| Usage update | Optional status detail | + +Terminal layout, color, width, animation, and key binding stay in `ui/`. +Domain status, approval authority, retry policy, and cursor progression do not. + +Every rendered item carries `TaskId`, projection sequence, and stable item ID. +The shell records the cursor only after the item has been applied to its local +view model. Rendering a transient frame is not a durable delivery receipt. + +## 7. Lifecycle and attach/detach semantics + +```mermaid +stateDiagram-v2 + [*] --> LocalOnly + LocalOnly --> Attaching: attach Task + Attaching --> Attached: snapshot and replay applied + Attaching --> Degraded: Gateway unavailable + Attached --> Attached: live projection items + Attached --> Capturing: approval or question focus + Capturing --> Attached: command accepted or focus cancelled + Attached --> Degraded: stream disconnected + Degraded --> Attaching: reconnect with cursor + Attached --> Detaching: user/session request + Detaching --> LocalOnly: receipt persisted + LocalOnly --> [*]: foreground shell exits + Attached --> [*]: shell exits; Task continues +``` + +### Attach + +1. Authenticate the local actor and shell instance. +2. Request a projection snapshot plus events after the shell's stored cursor. +3. Apply replay without executing terminal side effects. +4. Start the live stream only after the snapshot boundary is known. +5. Persist the highest contiguous applied cursor. + +### Detach + +- Stops the projection subscription and releases any interaction lease. +- Records the last applied cursor and reason. +- Cancels local card capture without deciding the underlying approval or + question. +- Does not close `AgentSessionId`, cancel `RunId`, stop a Task, or kill the PTY. + +### Shell exit + +The shell releases the attachment and PTY resources. A durable Task continues +unless the user submitted a separate cancel command. A best-effort detach +failure is recovered by attachment lease expiry. + +## 8. Direct local mode + +Direct local mode preserves the current terminal promise: + +- User-entered shell commands go directly to the foreground bash/zsh PTY. +- Pipes, redirects, interactive programs, signals, and job control do not wait + for Gateway admission. +- If the Gateway or Agent runtime is unavailable, the shell remains usable and + reports Agent/Task features as degraded. +- Direct commands may produce redacted shell evidence that a user explicitly + attaches to a Task, but are not retroactively treated as Agent-authorized + operations. + +Agent-requested commands are different. They require a Task execution request, +Broker permit, and `ForegroundPtyExecutionTarget` handoff before bytes reach +the PTY. The UI must visually distinguish direct user input from Agent-proposed +execution. + +The exact launch flag or configuration name for selecting direct-only versus +Gateway-attached startup remains an implementation decision. Phase 2 must not +remove the direct path. + +## 9. Foreground PTY execution target + +The current approved shell handoff can become an Execution Target adapter with +these invariants: + +1. The target is addressable only while the owning `ShellSessionId` and + attachment are live. +2. A Broker permit includes the command digest, actor, target, Task, expiry, + and expected shell readiness. +3. The shell verifies the permit and command correlation before enqueueing a + handoff. +4. Existing prompt/foreground detection prevents injection into a busy or + alternate-screen program. +5. Output and exit facts are correlated to `ExecutionId` and returned to the + Task plane. +6. Timeout or disconnect yields a typed interrupted or unknown outcome, never + an inferred success. + +An ACP `terminal/create` is not mapped to this interactive target by default. +It uses a separate governed background execution target. This prevents an +external Agent from taking over the user's live terminal simply because it +requested ACP terminal capability. + +## 10. Approval and input capture + +The Task plane owns the approval state. The shell owns only presentation and +keyboard capture. + +- An approval card is rendered from an `ApprovalView` with a stable version. +- The submitted decision contains `ApprovalId`, expected Task version, actor, + and idempotency key. +- A local “allow always” control is displayed only when the Approval Service + exposes an allowed durable policy scope. +- Disconnecting or cancelling card focus does not imply rejection. +- A decision is final only after the Gateway accepts it and a resolved + projection event is replayed. +- Stale, duplicate, unauthorized, or already-resolved decisions receive typed + errors and cannot execute a command. + +Questions use the same pattern. Secret input is not persisted in the generic +Task timeline; sensitive answers use a dedicated redacted or one-time channel +defined by the Phase 0 contract. + +## 11. Replay and delivery + +The shell keeps a small local attachment record: + +```text +task_id +attachment_id or previous client_instance_id +last_applied_cursor +last_projection_version +updated_at +``` + +On reconnect it requests events after `last_applied_cursor`. Events must be +applied idempotently by stable item ID and sequence. If the cursor is outside +retention, the Gateway returns a new snapshot and reset boundary; the shell +rebuilds Task cards without replaying terminal side effects or clearing the +PTY screen. + +The shell never replays raw PTY input. Shell evidence and Task projections are +separate streams linked by explicit evidence references. + +## 12. Errors, weak connectivity, and recovery + +| Failure | Required behavior | +| --- | --- | +| Gateway unavailable at startup | Start or retain direct local mode; show bounded degradation notice | +| Stream disconnect | Keep PTY active, stop Task input capture, reconnect with backoff and cursor | +| Replay gap/expired cursor | Rebuild Task view from snapshot and reset boundary | +| Duplicate projection item | Ignore by stable sequence/item ID | +| Command response lost | Retry only with the same idempotency key and reconcile from projection | +| Shell exits during pending approval | Release attachment; approval remains pending until policy timeout or another presenter acts | +| PTY busy when permitted handoff arrives | Queue within a bounded permit lifetime or reject as target unavailable | +| PTY output correlation lost | Record unknown execution outcome and require inspection | +| Task daemon restarts | Keep PTY running; reconnect and rehydrate projection | +| Terminal resize/card redraw failure | Recover terminal mode and preserve Task cursor before retrying presentation | + +No Gateway outage may freeze the foreground shell input/output relay. + +## 13. Security invariants + +- Attachment authentication does not grant OS execution by itself. +- Only the PTY-owning shell process may write to the foreground PTY master. +- Task event text, Agent markdown, and tool titles are untrusted rendering + input and must not emit control sequences outside the renderer policy. +- Projection replay never executes a command or re-submits a decision. +- Attachments are actor- and client-instance-scoped; stolen IDs are not bearer + credentials. +- Interaction leases bound concurrent approval/question input without making + viewers invisible. +- Shell evidence remains redacted and bounded before it crosses the Gateway. +- Direct user commands are labeled as user-originated and cannot be used as + proof that an Agent-held permit executed. + +## 14. Migration plan + +1. Extract a presenter-facing projection model without changing PTY behavior. +2. Add a Gateway client and attachment state behind a disabled feature path. +3. Render read-only Task replay next to current inline state. +4. Move prompt, cancel, approval, and question commands to the Gateway path. +5. Adapt approved foreground handoff to `ExecutionTargetPort`. +6. Remove Task-authoritative fields from `InlineState` only after parity and + restart tests pass. +7. Keep direct local mode and existing cosh-core path available as rollback. + +During migration, one user action must have exactly one owner. Dual-writing a +local approval and a Task approval is prohibited. + +## 15. Dependencies and task breakdown + +| Work item | Owner | Depends on | +| --- | --- | --- | +| Gateway attachment client | Shell attachment | Phase 1 Gateway API | +| Durable cursor cache | Shell attachment | Projection cursor contract | +| Shell projection presenter | `ui/` | Phase 0 presentation schema | +| Task command input adapter | Shell attachment | Task command/idempotency contract | +| Interaction lease handling | Shell + Task Plane | Attachment schema | +| Foreground PTY target adapter | `shell_host/` + Execution Target | Broker permit contract | +| Evidence reference adapter | Shell evidence owner | Evidence schema and redaction policy | +| Degraded direct-mode UX | `ui/` + runtime | Gateway health taxonomy | +| Attach/detach/replay tests | Shell attachment | Deterministic Gateway fake | + +New production code follows existing owner rules: PTY mechanics stay in +`shell_host/`, UI in `ui/`, and Task attachment orchestration in its approved +runtime owner. It must not add new root implementation modules. + +## 16. Test strategy + +### Pure and protocol tests + +- Cursor application, stable item deduplication, projection-to-card mapping, + and idempotent command encoding. +- Attach/detach state transitions, interaction lease expiry, and stale + decision rejection. +- Terminal control-sequence sanitization for untrusted projection content. + +### Shell host integration tests + +- Direct user commands, job control, `Ctrl+C`, resize, alternate screen, and + foreground process behavior with Gateway connected and disconnected. +- Detach while a Task is running without killing the PTY or Task. +- Daemon restart and cursor replay without duplicated cards or command + execution. +- Governed foreground handoff only at a safe prompt boundary. +- PTY owner exit produces a typed target-unavailable or unknown outcome. + +### Manual acceptance + +Manual TTY testing is required before release because terminal mode recovery +and visual card behavior are not fully proven by scripted PTYs. It must be +requested and recorded against the exact candidate commit; this planning work +does not perform that gate. + +## 17. Open questions + +- Is one Task attachment per shell sufficient initially, or must a shell show + several Tasks concurrently? +- Which client holds the default interaction lease when Shell and Web are both + attached? +- Should direct local commands be attachable as evidence automatically or only + after explicit user selection? +- What retention and encryption are required for the local cursor cache? +- Which existing inline state fields can be removed in Phase 2 versus a later + compatibility phase? diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design_zh.md new file mode 100644 index 0000000000..37ca174ab8 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/shell-attachment/design_zh.md @@ -0,0 +1,374 @@ +# Phase 2 Shell Attachment 设计 + +[English](design.md) + +状态:规划中,在基线 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 上尚未实现。 + +相关文档:[阶段规划](../../README_zh.md)和[验收报告](acceptance_zh.md)。 + +## 1. 决策 + +cosh-shell 继续拥有用户 foreground PTY、terminal mode、job control 和 direct +local shell 路径。Phase 2 在该路径旁增加 Task attachment。Attachment 消费 +Gateway projection 并提交 Task command,不把 PTY ownership 移入 Task daemon +或 ACP Bridge。 + +这会拆开目前共处一个进程的三种生命周期: + +```text +ShellSessionId foreground bash/zsh 与 PTY lifetime +TaskId 持久用户意图与治理 lifetime +AgentSessionId 一个 Agent Runtime conversation binding +``` + +Attach 和 detach 只作用于 Presentation membership,绝不隐含关闭 Agent +session、取消 Task 或终止 foreground shell。 + +## 2. 目标与非目标 + +### 目标 + +- 保留原生 bash/zsh 行为、foreground process group、signal、terminal resize、 + history 和用户接管能力。 +- 让一个 Shell attach 到持久 Task,并可从 cursor replay、approve、answer、 + cancel 和 detach。 +- 区分交互式 PTY output 与持久 Task event。 +- Local Gateway 或 Agent Runtime 不可用时,继续运行用户输入的 shell command。 +- Agent 请求的执行必须经过 Capability Broker,同时把现有 approved foreground + PTY handoff 保留为一种 Execution Target。 +- 让 Shell rendering 成为稳定 projection 上的 Presenter,不再拥有 Task state。 + +### 非目标 + +- Phase 2 把交互式 PTY master 移入 daemon。 +- 允许 Web 或 ACP peer 直接向用户 foreground PTY 写入。 +- 把每一条用户直接输入的 shell command 都变成 Task action。 +- 把 Terminal screen buffer 作为规范 Task transcript 持久化。 +- 支持多个 Shell/Web Client 并发控制 keystroke。 +- 迁移期间删除现有 Adapter、inline card、native Shell mode 或 non-AI + passthrough 路径。 + +## 3. 当前源码证据 + +| 证据 | 当前行为 | Phase 2 含义 | +| --- | --- | --- | +| [`shell_host/bootstrap.rs`](../../../../../crates/cosh-shell/src/shell_host/bootstrap.rs) | `PtySession` 拥有 master/slave file、child process、parser 和 recovery file;bash/zsh 成为带 controlling TTY 的 session leader | PTY lifetime 与 file descriptor 继续由 Shell process 拥有 | +| [`shell_host/raw_runner.rs`](../../../../../crates/cosh-shell/src/shell_host/raw_runner.rs) | Relay input/output,跟踪 process group、terminal size、prompt gate 和 child exit | Attachment 不得阻塞或替代 relay loop | +| [`shell_host/model.rs`](../../../../../crates/cosh-shell/src/shell_host/model.rs) | `ShellHostConfig` 默认 native mode,并可禁用 AI classification | Direct local shell 继续作为明确 compatibility boundary | +| [`runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) | `InlineState` 在内存保存 approval、question、Agent run、event cursor、card、shell block 和 session ID | 持久 Task state 必须移到 Gateway 后方;`InlineState` 变成 view/cache state | +| [`runtime/dispatcher.rs`](../../../../../crates/cosh-shell/src/runtime/dispatcher.rs) | Shell event snapshot 驱动 inline action 和 rendering | Presenter 可复用 rendering 概念,但不能把 shell-event domain 当作 Task schema | +| [`runtime/controller.rs`](../../../../../crates/cosh-shell/src/runtime/controller.rs) | Inline rendering 同时向 PTY 发射 approved handoff 并 capture card input | Task command 与 PTY action 需要独立 Port 和显式 correlation | +| [`shell_host/lifecycle.rs`](../../../../../crates/cosh-shell/src/shell_host/lifecycle.rs) | Host 结束时对 Shell event 脱敏并写入 `events.jsonl` | 该 journal 是 Shell evidence,不是持久 Task EventStore 或 Outbox | +| [`adapter/cosh_core.rs`](../../../../../crates/cosh-shell/src/adapter/cosh_core.rs) | Provider session state 和 recovery 位于 Shell Adapter 后 | Phase 2 把 Agent Runtime ownership 移到 `AgentRuntimePort` 后方 | + +基线不存在 Gateway Client、Task attachment record、持久 replay cursor、跨进程 +Presenter lease 或 attach/detach API。 + +## 4. Ownership Model + +```mermaid +flowchart TB + USER["User keyboard/display"] <--> HOST["ShellHost / PTY owner"] + HOST <--> CHILD["Foreground bash/zsh + jobs"] + + HOST --> OBS["Shell evidence observer"] + OBS --> ATTACH["ShellAttachmentController"] + ATTACH <--> GW["Gateway API"] + GW <--> TASK["TaskCoordinator + Projection"] + + TASK --> BROKER["CapabilityBroker"] + BROKER --> TARGET["ForegroundPtyExecutionTarget"] + TARGET --> ATTACH + ATTACH --> HOST + + TASK --> EVENTS["Projection event stream"] + EVENTS --> PRES["ShellPresenter"] + PRES --> HOST +``` + +### Shell 拥有 + +- PTY master/slave descriptor 和 foreground child process。 +- Terminal raw/cooked mode recovery、resize、signal 和 job-control 行为。 +- Keystroke capture 与本地 card focus。 +- 用户直接输入的 command 和 Terminal output。 +- 有界 local view cache 与最后确认的 Task cursor。 + +### Gateway/Task 拥有 + +- Task 与 Run state、Agent binding、approval、question、execution record、 + projection sequence 和 replay。 +- Attachment membership 与可选 single-writer interaction lease。 +- Shell 提交 command 的 idempotency 与 authorization。 + +### Broker/Execution Target 拥有 + +- Agent 请求的 shell execution authorization 与 permit。 +- Operation digest、target binding、timeout、result 与 unknown-outcome state。 + +Shell 只对 PTY 可观测事实具有权威性。Task Plane 提交对应 execution result +之前,Shell 不得宣告 Task action 完成。 + +## 5. Shell Attachment Port + +Shell 消费与其他 Client 相同的 versioned Gateway API。未来可以加入本地 +in-process 优化,但必须遵循相同 schema 与 authorization check。 + +概念 command: + +```text +AttachTask { + task_id, + shell_session_id, + actor_id, + after_cursor, + capabilities, + client_instance_id +} + +DetachTask { task_id, attachment_id, last_applied_cursor, reason } +SubmitPrompt { task_id, expected_version, content, idempotency_key } +ResolveApproval { task_id, approval_id, decision, expected_version, idempotency_key } +AnswerQuestion { task_id, question_id, answer, expected_version, idempotency_key } +CancelRun { task_id, run_id, reason, idempotency_key } +ClaimInteraction { task_id, attachment_id, ttl } +RenewInteraction { task_id, attachment_id, lease_token } +``` + +`AttachTask` 返回 `AttachmentId`、当前 projection version、有序 replay page 与 +next cursor。`after_cursor` 是 Task event cursor,绝不是 Shell OSC index 或 ACP +message ID。 + +## 6. Presentation Schema + +Shell Presenter 把稳定 projection item 映射到现有或新增 card model。映射是 +单向的: + +| Projection item | Shell Surface | +| --- | --- | +| Task/Run status | Status 或 notice panel | +| Agent message chunk | Markdown stream card | +| Plan update | Plan/activity panel | +| Tool declaration/update | Tool invocation row | +| Approval pending/resolved | Approval panel 与 receipt | +| User question | Question panel | +| Execution output reference | 按需获取的有界 detail view | +| Runtime failure/recovery | Recoverable error notice | +| Usage update | 可选 status detail | + +Terminal layout、color、width、animation 与 key binding 留在 `ui/`。Domain +status、approval authority、retry policy 与 cursor progression 不得留在 UI。 + +每个 rendered item 都携带 `TaskId`、projection sequence 与稳定 item ID。 +只有 item 已应用到 local view model 后,Shell 才记录 cursor。渲染一个临时 +frame 不等于持久 delivery receipt。 + +## 7. 生命周期与 Attach/Detach 语义 + +```mermaid +stateDiagram-v2 + [*] --> LocalOnly + LocalOnly --> Attaching: attach Task + Attaching --> Attached: snapshot and replay applied + Attaching --> Degraded: Gateway unavailable + Attached --> Attached: live projection items + Attached --> Capturing: approval or question focus + Capturing --> Attached: command accepted or focus cancelled + Attached --> Degraded: stream disconnected + Degraded --> Attaching: reconnect with cursor + Attached --> Detaching: user/session request + Detaching --> LocalOnly: receipt persisted + LocalOnly --> [*]: foreground shell exits + Attached --> [*]: shell exits; Task continues +``` + +### Attach + +1. 验证 local actor 和 Shell instance。 +2. 请求 projection snapshot 以及 Shell 已存 cursor 之后的 event。 +3. 应用 replay,但不执行 Terminal side effect。 +4. 已知 snapshot boundary 后才启动 live stream。 +5. 持久化最高的连续 applied cursor。 + +### Detach + +- 停止 projection subscription 并释放 interaction lease。 +- 记录最后 applied cursor 与 reason。 +- 取消本地 card capture,但不对底层 approval 或 question 作决定。 +- 不关闭 `AgentSessionId`、不取消 `RunId`、不停止 Task,也不杀死 PTY。 + +### Shell 退出 + +Shell 释放 attachment 和 PTY resource。除非用户另行提交 cancel command,持久 +Task 继续运行。Best-effort detach 失败由 attachment lease expiry 恢复。 + +## 8. Direct Local Mode + +Direct local mode 保持现有 Terminal 承诺: + +- 用户输入的 shell command 直接进入 foreground bash/zsh PTY。 +- Pipe、redirect、interactive program、signal 和 job control 不等待 Gateway + admission。 +- Gateway 或 Agent Runtime 不可用时,Shell 保持可用,并把 Agent/Task feature + 显示为 degraded。 +- Direct command 可产生脱敏 Shell evidence,并由用户明确 attach 到 Task,但不能 + 追溯解释为经过 Agent authorization 的 operation。 + +Agent 请求的 command 不同。Bytes 进入 PTY 前,必须有 Task execution request、 +Broker permit 与 `ForegroundPtyExecutionTarget` handoff。UI 必须明显区分用户 +直接输入和 Agent-proposed execution。 + +选择 direct-only 或 Gateway-attached startup 的准确 launch flag 或 config name +留给实现决定。Phase 2 不得移除 direct path。 + +## 9. Foreground PTY Execution Target + +现有 approved shell handoff 可以演进为 Execution Target Adapter,并满足: + +1. 只有 owner `ShellSessionId` 和 attachment 存活时,该 target 才可寻址。 +2. Broker permit 包含 command digest、actor、target、Task、expiry 和预期 shell + readiness。 +3. Shell 在 handoff 入队前验证 permit 与 command correlation。 +4. 使用现有 prompt/foreground detection 防止向 busy 或 alternate-screen program + 注入。 +5. Output 与 exit fact 关联到 `ExecutionId` 并返回 Task Plane。 +6. Timeout 或 disconnect 产生 typed interrupted/unknown outcome,不能推断成功。 + +ACP `terminal/create` 默认不映射到该交互式 target。它使用独立的 governed +background execution target,避免外部 Agent 仅因请求 ACP terminal capability +就接管用户 live terminal。 + +## 10. Approval 与 Input Capture + +Task Plane 拥有 approval state。Shell 只拥有 Presentation 与 keyboard capture。 + +- Approval card 从带稳定 version 的 `ApprovalView` 渲染。 +- 提交的 decision 包含 `ApprovalId`、expected Task version、actor 和 idempotency + key。 +- 只有 Approval Service 暴露可用持久 policy scope 时,才显示本地 + “allow always” control。 +- Disconnect 或取消 card focus 不表示 reject。 +- 只有 Gateway 接受 decision 并 replay resolved projection event 后,decision + 才算最终完成。 +- Stale、duplicate、unauthorized 或已 resolved decision 返回 typed error,且不能 + 执行 command。 + +Question 使用相同模式。Secret input 不写入通用 Task timeline;敏感 answer 使用 +Phase 0 contract 定义的专用脱敏或 one-time channel。 + +## 11. Replay 与 Delivery + +Shell 保存很小的本地 attachment record: + +```text +task_id +attachment_id or previous client_instance_id +last_applied_cursor +last_projection_version +updated_at +``` + +重连时请求 `last_applied_cursor` 之后的 event。必须按稳定 item ID 和 sequence +幂等应用。如果 cursor 已超出 retention,Gateway 返回新 snapshot 和 reset +boundary;Shell 重建 Task card,但不 replay Terminal side effect,也不清空 PTY +screen。 + +Shell 永不 replay raw PTY input。Shell evidence 与 Task projection 是通过显式 +evidence reference 关联的独立 stream。 + +## 12. Error、弱网与 Recovery + +| 故障 | 必须的行为 | +| --- | --- | +| 启动时 Gateway 不可用 | 启动或保留 direct local mode;显示有界 degradation notice | +| Stream disconnect | 保持 PTY active,停止 Task input capture,带 backoff 和 cursor 重连 | +| Replay gap/expired cursor | 从 snapshot 和 reset boundary 重建 Task view | +| 重复 projection item | 按稳定 sequence/item ID 忽略 | +| Command response 丢失 | 只使用相同 idempotency key retry,并通过 projection 对账 | +| Pending approval 时 Shell 退出 | 释放 attachment;approval 保持 pending,直到 policy timeout 或其他 Presenter 处理 | +| Permitted handoff 到达时 PTY busy | 在有界 permit lifetime 内排队,或按 target unavailable 拒绝 | +| PTY output correlation 丢失 | 记录 unknown execution outcome 并要求检查 | +| Task daemon 重启 | 保持 PTY 运行;重连并 rehydrate projection | +| Terminal resize/card redraw 失败 | 恢复 Terminal mode,保存 Task cursor 后重试 Presentation | + +Gateway 故障不得冻结 foreground shell input/output relay。 + +## 13. 安全 Invariant + +- Attachment authentication 本身不授予 OS execution。 +- 只有拥有 PTY 的 Shell process 可以写 foreground PTY master。 +- Task event text、Agent markdown 和 tool title 都是不可信 rendering input,不得 + 在 renderer policy 外发出 control sequence。 +- Projection replay 永不执行 command 或重新提交 decision。 +- Attachment 带 actor 和 client-instance scope;窃取 ID 不等于获得 bearer + credential。 +- Interaction lease 限制并发 approval/question input,但 viewer 仍可见。 +- Shell evidence 跨越 Gateway 前保持脱敏和有界。 +- Direct user command 标记为 user-originated,不能作为 Agent-held permit 已执行的 + 证明。 + +## 14. 迁移计划 + +1. 在不改变 PTY 行为的前提下抽取 Presenter-facing projection model。 +2. 在默认关闭的 feature path 后添加 Gateway Client 和 attachment state。 +3. 在当前 inline state 旁渲染 read-only Task replay。 +4. 把 prompt、cancel、approval 和 question command 移到 Gateway 路径。 +5. 把 approved foreground handoff 适配到 `ExecutionTargetPort`。 +6. 只有 parity 与 restart test 通过后,才从 `InlineState` 移除 Task-authoritative + field。 +7. 保留 direct local mode 与现有 cosh-core 路径用于 rollback。 + +迁移期间一个用户 action 只能有一个 owner。禁止同时写 local approval 与 Task +approval。 + +## 15. 依赖与任务分解 + +| Work item | Owner | 依赖 | +| --- | --- | --- | +| Gateway attachment client | Shell attachment | Phase 1 Gateway API | +| Durable cursor cache | Shell attachment | Projection cursor contract | +| Shell projection presenter | `ui/` | Phase 0 presentation schema | +| Task command input adapter | Shell attachment | Task command/idempotency contract | +| Interaction lease handling | Shell + Task Plane | Attachment schema | +| Foreground PTY target adapter | `shell_host/` + Execution Target | Broker permit contract | +| Evidence reference adapter | Shell evidence owner | Evidence schema 和 redaction policy | +| Degraded direct-mode UX | `ui/` + runtime | Gateway health taxonomy | +| Attach/detach/replay test | Shell attachment | Deterministic Gateway fake | + +新增 production code 遵守现有 owner rule:PTY mechanics 留在 `shell_host/`,UI +留在 `ui/`,Task attachment orchestration 留在批准的 Runtime owner。不得新增 +root implementation module。 + +## 16. 测试策略 + +### Pure 与 Protocol Test + +- Cursor application、稳定 item deduplication、projection-to-card mapping 与 + idempotent command encoding。 +- Attach/detach state transition、interaction lease expiry 和 stale decision + rejection。 +- 不可信 projection content 的 Terminal control-sequence sanitization。 + +### Shell Host Integration Test + +- Gateway 连接和断开时的 direct user command、job control、`Ctrl+C`、resize、 + alternate screen 和 foreground process 行为。 +- Task 运行时 detach,不杀死 PTY 或 Task。 +- Daemon restart 与 cursor replay 不产生重复 card 或 command execution。 +- Governed foreground handoff 只在安全 prompt boundary 执行。 +- PTY owner 退出后产生 typed target-unavailable 或 unknown outcome。 + +### 手工验收 + +Release 前需要手工 TTY test,因为 scripted PTY 不能完全证明 terminal mode +recovery 和可视 card 行为。必须对准确 candidate commit 单独请求和记录;本次 +规划工作不执行该 gate。 + +## 17. 开放问题 + +- 首版是否一个 Shell 只需 attach 一个 Task,还是必须同时展示多个 Task? +- Shell 与 Web 都 attach 时,默认 interaction lease 归哪个 Client? +- Direct local command 应自动可 attach 为 evidence,还是只允许用户显式选择? +- Local cursor cache 需要怎样的 retention 与 encryption? +- 哪些现有 inline state field 可以在 Phase 2 移除,哪些应留到后续兼容阶段? diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance.md new file mode 100644 index 0000000000..5ccbb23d01 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance.md @@ -0,0 +1,126 @@ +# Phase 2 Web and Presentation Acceptance Report + +[中文版](acceptance_zh.md) + +Related design: [Web and Presentation design](design.md). + +## 1. Report scope + +- Baseline reviewed: `6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- Review date: 2026-08-12 +- Change type: planning documentation only +- Implementation acceptance: **NOT ACCEPTED** + +This report defines future evidence. It does not claim that a Web surface, +Gateway API, Projection, Outbox, or delivery receipt exists. + +## 2. Baseline evidence + +The baseline has terminal-specific rendering and process-local shell state. It +has no Web component, browser transport, Gateway HTTP API, Task projection, +transactional Outbox, ordered presentation stream, delivery receipt, or +multi-client attachment state. + +The cosh-core JSONL stream and the shell `events.jsonl` journal are private +runtime/evidence formats. Neither is accepted as a browser API or Web replay +source. + +## 3. Current readiness + +| Area | Baseline status | Acceptance status | Evidence needed to pass | +| --- | --- | --- | --- | +| Web-safe projection schema | Not present | **NOT IMPLEMENTED** | Versioned schema and golden fixtures | +| Gateway HTTP command/query API | Not present | **NOT IMPLEMENTED** | Authenticated contract tests | +| Ordered event stream | Not present | **NOT IMPLEMENTED** | Cursor/reconnect integration tests | +| Transactional Projection + Outbox | Not present | **NOT IMPLEMENTED** | Crash-boundary transaction tests | +| Delivery worker | Not present | **NOT IMPLEMENTED** | Lease, retry, ordering, and duplicate tests | +| Delivery receipt | Not present | **NOT IMPLEMENTED** | Monotonic per-client watermark tests | +| Attach/detach record | Not present | **NOT IMPLEMENTED** | Lifecycle and expiry tests | +| Browser reducer | Not present | **NOT IMPLEMENTED** | Duplicate/gap/reset golden tests | +| Web approval/question controls | Not present | **NOT IMPLEMENTED** | Version/idempotency and sensitive-input tests | +| Interaction lease | Not present | **NOT IMPLEMENTED** | Shell/Web contention tests | +| Bounded execution output view | Not present | **NOT IMPLEMENTED** | Auth, expiry, redaction, and bounds tests | +| Weak-network recovery | Not present | **NOT IMPLEMENTED** | Offline/restart/slow-client test harness | +| Web security controls | Not present | **NOT IMPLEMENTED** | Threat review and adversarial tests | +| Direct-boundary enforcement | No Web path exists | **NOT IMPLEMENTED** | Proof Web cannot call ACP/PTY/store/target directly | + +## 4. Exit criteria + +| ID | Criterion | Required proof | +| --- | --- | --- | +| WEB-01 | Browser uses only the authorized Gateway API and Presentation Port | Dependency and route review plus negative tests | +| WEB-02 | Web never consumes ACP JSON-RPC or cosh-core JSONL | Boundary test and dependency audit | +| WEB-03 | Projection schema is versioned, bounded, redacted, and runtime-neutral | Schema fixtures and payload-limit tests | +| WEB-04 | Task event, projection update, Outbox record, and idempotency result commit atomically | Pre/post-commit crash tests | +| WEB-05 | Outbox publication is ordered per Task and safe to repeat | Multi-worker lease and duplicate tests | +| WEB-06 | Attach has an atomic snapshot/stream boundary | Concurrent-update race test | +| WEB-07 | Reconnect replays after the highest contiguous applied cursor | Disconnect/reconnect integration test | +| WEB-08 | Expired cursor returns a typed reset and authorized fresh snapshot | Retention-gap test | +| WEB-09 | Receipts advance monotonically per actor/client/attachment/Task and reject gaps | Receipt contract tests | +| WEB-10 | A receipt never means user approval, viewing, or command acceptance | Domain/API review and state-transition tests | +| WEB-11 | Lost command responses reconcile by idempotency key and projection | Timeout and duplicate-submit tests | +| WEB-12 | Approval/question commands reject stale, duplicate, unauthorized, and resolved input | Conflict and authorization tests | +| WEB-13 | Multiple viewers cannot accidentally become concurrent writers | Interaction-lease contention/expiry tests | +| WEB-14 | Slow or offline clients cannot create unbounded server queues | Backpressure and later-replay tests | +| WEB-15 | Gateway and delivery-worker restarts do not lose committed visible state | Restart durability tests | +| WEB-16 | Projection and output rendering resists HTML/Markdown/URL/control injection | Adversarial rendering suite | +| WEB-17 | Secrets and raw credentials do not reach projection, logs, URLs, receipts, or local storage | Data-flow review and secret-canary tests | +| WEB-18 | Authorization revocation closes streams and prevents replay/commands | Revocation end-to-end test | +| WEB-19 | Execution output uses authorized expiring bounded references | Access, expiry, and size tests | +| WEB-20 | Web can be disabled without changing Shell direct mode or Task durability | Rollback smoke test | + +All WEB-01 through WEB-20 criteria are mandatory for Phase 2 exit. + +## 5. Required automated evidence + +The future implementation report must record: + +- full candidate commit SHA and exact targeted commands/test counts; +- schema versions for Gateway, ProjectionEnvelope, snapshot, and receipt; +- database and transaction backend used by the tests; +- Outbox claim lease, retry, queue, payload, and retention limits; +- stream transport and proxy assumptions; +- authorization, CSRF/origin, token/cookie, and redaction test coverage; +- deterministic weak-network cases: drop, delay, duplicate, reordering, sleep, + restart, and slow consumer; +- untested browsers, remote deployment modes, and optional transports. + +Expected targeted test groups, with final commands chosen by implementation +ownership: + +```text + + + + + +``` + +No code suites were run for this documentation-only report because those +modules are not implemented. + +## 6. Manual and deployment evidence + +Before a remotely exposed release, an explicitly requested manual/browser gate +must verify attach, replay, live updates, approvals, cancellation, multi-device +receipts, offline recovery, authorization revocation, and responsive rendering. +Deployment review must cover the actual reverse proxy and authentication +mode. No browser, public-network, ECS, provider, or screenshot validation was +performed here. + +## 7. Remaining blockers + +- Phase 0 projection, cursor, attachment, identity, redaction, and error + contracts must be accepted. +- Phase 1 Gateway, Task Plane, transactional EventStore/Projection/Outbox, and + authorization paths must exist. +- Stream binding and actual deployment authentication are not yet selected. +- Event, output, and receipt retention limits need approved values. +- Viewer/operator role boundaries and interaction-lease granularity remain + open. + +## 8. Acceptance decision + +**NOT IMPLEMENTED / NOT ACCEPTED.** The module can exit Phase 2 only after +WEB-01 through WEB-20 pass on one candidate revision and any requested +deployment/manual gate records sanitized evidence. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance_zh.md new file mode 100644 index 0000000000..9b5b4d368b --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/acceptance_zh.md @@ -0,0 +1,121 @@ +# Phase 2 Web 与 Presentation 验收报告 + +[English](acceptance.md) + +相关设计:[Web 与 Presentation 设计](design_zh.md)。 + +## 1. 报告范围 + +- 审计基线:`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` +- 审计日期:2026-08-12 +- 变更类型:仅规划文档 +- 实现验收:**NOT ACCEPTED** + +本报告定义未来证据,不表示 Web surface、Gateway API、Projection、Outbox 或 +delivery receipt 已经存在。 + +## 2. 基线证据 + +基线包含 Terminal-specific rendering 和 process-local Shell state,没有 Web +component、Browser transport、Gateway HTTP API、Task projection、transactional +Outbox、有序 Presentation stream、delivery receipt 或 multi-client attachment +state。 + +cosh-core JSONL stream 和 Shell `events.jsonl` journal 是私有 Runtime/evidence +format,均未被接受为 Browser API 或 Web replay source。 + +## 3. 当前就绪度 + +| 领域 | 基线状态 | 验收状态 | 通过所需证据 | +| --- | --- | --- | --- | +| Web-safe projection schema | 不存在 | **NOT IMPLEMENTED** | Versioned schema 和 golden fixture | +| Gateway HTTP command/query API | 不存在 | **NOT IMPLEMENTED** | Authenticated contract test | +| Ordered event stream | 不存在 | **NOT IMPLEMENTED** | Cursor/reconnect integration test | +| Transactional Projection + Outbox | 不存在 | **NOT IMPLEMENTED** | Crash-boundary transaction test | +| Delivery worker | 不存在 | **NOT IMPLEMENTED** | Lease、retry、ordering 与 duplicate test | +| Delivery receipt | 不存在 | **NOT IMPLEMENTED** | Monotonic per-client watermark test | +| Attach/detach record | 不存在 | **NOT IMPLEMENTED** | Lifecycle 与 expiry test | +| Browser reducer | 不存在 | **NOT IMPLEMENTED** | Duplicate/gap/reset golden test | +| Web approval/question control | 不存在 | **NOT IMPLEMENTED** | Version/idempotency 与 sensitive-input test | +| Interaction lease | 不存在 | **NOT IMPLEMENTED** | Shell/Web contention test | +| Bounded execution output view | 不存在 | **NOT IMPLEMENTED** | Auth、expiry、redaction 与 bounds test | +| 弱网恢复 | 不存在 | **NOT IMPLEMENTED** | Offline/restart/slow-client test harness | +| Web security control | 不存在 | **NOT IMPLEMENTED** | Threat review 与 adversarial test | +| Direct-boundary enforcement | 尚无 Web 路径 | **NOT IMPLEMENTED** | Web 不能直接访问 ACP/PTY/store/target 的证明 | + +## 4. Exit Criteria + +| ID | 标准 | 必需证明 | +| --- | --- | --- | +| WEB-01 | Browser 只使用 authorized Gateway API 和 Presentation Port | Dependency/route review 加 negative test | +| WEB-02 | Web 永不消费 ACP JSON-RPC 或 cosh-core JSONL | Boundary test 与 dependency audit | +| WEB-03 | Projection schema 有版本、有界、脱敏且 Runtime-neutral | Schema fixture 与 payload-limit test | +| WEB-04 | Task event、projection update、Outbox record 和 idempotency result 原子提交 | Commit 前后 crash test | +| WEB-05 | Outbox 按 Task 有序发布并允许安全重复 | Multi-worker lease 与 duplicate test | +| WEB-06 | Attach 具有 atomic snapshot/stream boundary | Concurrent-update race test | +| WEB-07 | 重连从最高连续 applied cursor 后 replay | Disconnect/reconnect integration test | +| WEB-08 | Cursor 过期时返回 typed reset 与 authorized fresh snapshot | Retention-gap test | +| WEB-09 | Receipt 按 actor/client/attachment/Task 单调前进并拒绝 gap | Receipt contract test | +| WEB-10 | Receipt 永不表示用户批准、看到内容或 command 被接受 | Domain/API review 与 state-transition test | +| WEB-11 | 丢失的 command response 按 idempotency key 和 projection reconcile | Timeout 与 duplicate-submit test | +| WEB-12 | Approval/question command 拒绝 stale、duplicate、unauthorized 与 resolved input | Conflict 与 authorization test | +| WEB-13 | 多个 viewer 不会意外成为并发 writer | Interaction-lease contention/expiry test | +| WEB-14 | Slow/offline Client 不产生无界 server queue | Backpressure 与 later-replay test | +| WEB-15 | Gateway 与 delivery-worker 重启不丢失已 committed visible state | Restart durability test | +| WEB-16 | Projection 与 output rendering 防御 HTML/Markdown/URL/control injection | Adversarial rendering suite | +| WEB-17 | Secret 和 raw credential 不进入 projection、log、URL、receipt 或 local storage | Data-flow review 与 secret-canary test | +| WEB-18 | Authorization revocation 关闭 stream 并阻止 replay/command | Revocation end-to-end test | +| WEB-19 | Execution output 使用 authorized、expiring、bounded reference | Access、expiry 与 size test | +| WEB-20 | 禁用 Web 不改变 Shell direct mode 或 Task durability | Rollback smoke test | + +退出 Phase 2 必须满足 WEB-01 至 WEB-20 全部标准。 + +## 5. 必需自动化证据 + +未来实现报告必须记录: + +- Candidate 完整 commit SHA 与准确 targeted command/test count; +- Gateway、ProjectionEnvelope、snapshot 与 receipt 的 schema version; +- Test 使用的 database 和 transaction backend; +- Outbox claim lease、retry、queue、payload 与 retention limit; +- Stream transport 和 proxy assumption; +- Authorization、CSRF/origin、token/cookie 与 redaction test coverage; +- 确定性弱网案例,包括 drop、delay、duplicate、reordering、sleep、restart 和 + slow consumer; +- 尚未测试的 Browser、远端 deployment mode 与 optional transport。 + +预期 targeted test group 如下,最终 command 由实现 owner 决定: + +```text + + + + + +``` + +本次仅文档报告未运行 code suite,因为这些模块尚未实现。 + +## 6. 手工与 Deployment 证据 + +远端暴露 release 前,必须明确请求 manual/browser gate,并验证 attach、replay、 +live update、approval、cancellation、multi-device receipt、offline recovery、 +authorization revocation 与 responsive rendering。Deployment review 必须覆盖 +真实 reverse proxy 与 authentication mode。本次没有执行 Browser、public- +network、ECS、provider 或 screenshot validation。 + +## 7. 剩余 Blocker + +- 必须验收 Phase 0 projection、cursor、attachment、identity、redaction 与 error + contract。 +- 必须具备 Phase 1 Gateway、Task Plane、transactional EventStore/Projection/ + Outbox 与 authorization 路径。 +- Stream binding 和真实 deployment authentication 尚未选择。 +- Event、output 与 receipt retention limit 需要批准具体值。 +- Viewer/operator role boundary 与 interaction-lease granularity 尚未确定。 + +## 8. 验收决定 + +**NOT IMPLEMENTED / NOT ACCEPTED。** 只有 WEB-01 至 WEB-20 在同一 candidate +revision 全部通过,并且所有被请求的 deployment/manual gate 都记录脱敏证据后, +模块才能退出 Phase 2。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design.md new file mode 100644 index 0000000000..b7b7a4c9ed --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design.md @@ -0,0 +1,448 @@ +# Phase 2 Web and Presentation Design + +[中文版](design_zh.md) + +Status: planned, not implemented on baseline +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52`. + +Related documents: [phase plan](../../README.md) and +[acceptance report](acceptance.md). + +## 1. Decision + +The Phase 2 Web client is a presentation adapter over the COSH Gateway API and +versioned Task projections. It never connects to ACP, an Agent subprocess, a +PTY, the Task database, or the Outbox table directly. + +The first delivery profile uses: + +- versioned HTTP JSON commands and queries through the Gateway; +- an ordered server event stream with a replay cursor; +- transactional Projection and Outbox writes in the Task plane; +- per-client delivery receipts for the highest contiguous applied cursor; +- snapshot reset when a cursor is outside retention. + +SSE is the preferred first stream binding because commands remain explicit +HTTP requests and reconnect uses a cursor. WebSocket can be added behind the +same Presentation Port later; it is not ACP remote transport and must not +change Task semantics. + +## 2. Goals and non-goals + +### Goals + +- View Task state and Agent progress from a browser without logging into a + shell. +- Attach, detach, replay, prompt, approve, answer, cancel, and inspect results + through the same Gateway contract used by other clients. +- Deliver ordered projection changes reliably across refresh, reconnect, + device sleep, weak networks, and daemon restart. +- Keep presentation-specific layout separate from Task and Runtime schemas. +- Support multiple viewers while controlling concurrent interactive actions. +- Preserve redaction, authorization, and target scope on every read and + command. + +### Non-goals + +- Implementing an in-browser ACP client or exposing ACP stdio over WebSocket. +- Rendering or controlling the user's foreground PTY. +- Making browser local storage the source of truth. +- Giving the Web server direct database, Outbox, Agent, or OS execution access. +- Guaranteeing exactly-once network delivery. The design provides ordered + at-least-once delivery with idempotent application and receipts. +- General DingTalk/Feishu channel adapters in Phase 2. +- A full Warp-style terminal emulator or code editor. + +## 3. Current source evidence + +| Evidence | Current behavior | Phase 2 gap | +| --- | --- | --- | +| [`ui/agent_render`](../../../../../crates/cosh-shell/src/ui/agent_render/mod.rs) | Terminal-specific panels, markdown, approval, question, activity, and tool rendering | Rendering models are not a versioned network projection contract | +| [`runtime/dispatcher.rs`](../../../../../crates/cosh-shell/src/runtime/dispatcher.rs) | In-process shell event snapshots drive UI actions | No cross-process replay, authorization, or delivery receipt | +| [`runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) | View and lifecycle state is held in `InlineState` | State is process-local and shell-specific | +| [`shell_host/lifecycle.rs`](../../../../../crates/cosh-shell/src/shell_host/lifecycle.rs) | Redacted shell events are written to a local JSONL journal | The journal is not a Task projection, EventStore, or Web API | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | Internal JSONL streams contain messages, tools, questions, approvals, and results | This private protocol cannot be exposed to a browser | +| [`cosh-core/session.rs`](../../../../../crates/cosh-core/src/session.rs) | Provider conversations are persisted by workspace | Provider history is not an authorized multi-client Task view | + +The baseline has no Web crate, browser bundle, Gateway HTTP API, SSE endpoint, +Task projection, Outbox, delivery receipt, or Web authentication path. + +## 4. Architecture and ownership + +```mermaid +flowchart LR + B["Browser"] -->|"HTTP commands/queries"| GW["Gateway API"] + B <--> |"event stream + cursor"| STREAM["Presentation stream"] + GW --> TC["TaskCoordinator"] + TC --> TX["Transactional commit"] + TX --> ES[("Task/Event store")] + TX --> PROJ[("Projection")] + TX --> OB[("Outbox")] + OB --> DW["Delivery worker"] + DW --> STREAM + B -->|"delivery receipt"| GW + GW --> RECEIPT[("Receipt watermark")] +``` + +### Gateway-owned + +- Authentication, actor resolution, authorization, rate limits, schema + negotiation, command validation, and idempotency admission. +- Query and stream endpoints over the Presentation Port. +- Receipt validation and attachment lifecycle commands. + +### Task-plane-owned + +- Canonical Task events, aggregate state, projection, event sequence, Outbox + records, attachment records, and interaction leases. +- Atomic command result: Task event, projection update, and Outbox record are + committed in one transaction boundary. + +### Delivery-worker-owned + +- Claiming pending Outbox records, publishing ordered projection envelopes, + retry/backoff, lease recovery, and delivery metrics. +- It does not interpret domain policy or mark a user action accepted. + +### Browser-owned + +- Layout, filters, local navigation, an idempotent view reducer, last applied + cursor, and ephemeral input drafts. +- Browser state is disposable and never authorizes a Task transition. + +## 5. Gateway surface + +Exact paths are frozen by the Phase 1 Gateway API, but the Phase 2 Web adapter +requires these conceptual operations: + +```text +CreateTask +GetTaskProjection +ListAuthorizedTasks +AttachTask +DetachTask +SubmitPrompt +ResolveApproval +AnswerQuestion +CancelRun +ClaimInteraction / RenewInteraction / ReleaseInteraction +ReadExecutionOutput +OpenProjectionStream(after_cursor) +AcknowledgeDelivery(highest_contiguous_cursor) +``` + +Every mutation carries: + +```text +actor identity resolved by Gateway +task_id +expected aggregate/projection version where applicable +idempotency_key +client_instance_id +attachment_id when attached interaction is required +``` + +The Web adapter receives typed errors for unauthorized, conflict, stale +version, already resolved, invalid state, rate limited, and temporarily +unavailable outcomes. It must not infer success from an HTTP connection close. + +## 6. Projection schema + +The projection is optimized for safe presentation, not runtime rehydration. + +### Task summary + +```text +TaskSummaryView { + task_id, + title, + status, + current_run_id?, + target_summary, + created_at, + updated_at, + version, + unread_or_attention_state +} +``` + +### Task detail + +```text +TaskDetailView { + summary, + agent_session_summary?, + plan, + timeline_items, + pending_approvals, + pending_questions, + executions, + usage?, + attachments, + available_actions, + projection_version, + snapshot_cursor +} +``` + +### Stream envelope + +```text +ProjectionEnvelope { + schema_version, + task_id, + sequence, + item_id, + event_type, + occurred_at, + projection_version, + payload, + redaction_class, + replay +} +``` + +`available_actions` is advisory UI data; Gateway command validation remains +authoritative. Raw model payloads, secrets, environment values, unbounded +terminal output, and provider credentials never enter the generic projection. + +## 7. Runtime and ACP event presentation + +The Task plane normalizes runtime-specific events before presentation: + +| Domain projection item | Web component | +| --- | --- | +| Task/Run state | Header and status timeline | +| Agent message/thought chunks | Grouped message blocks with policy-based visibility | +| Agent plan | Structured plan list | +| Tool use and update | Tool activity card | +| Approval pending/resolved | Decision card and immutable receipt | +| Question pending/answered | Input card and answer state | +| Execution state/output reference | Execution card and paged output viewer | +| Usage update | Context/cost indicator | +| Runtime failure/recovery | Recovery notice and allowed actions | + +ACP `messageId`, `toolCallId`, `terminalId`, and `sessionId` are never public +Task identities. A presenter may receive a stable normalized message/tool item +ID. It does not consume ACP JSON-RPC. + +## 8. Outbox and transaction semantics + +For every user-visible Task transition, one database transaction must: + +1. validate the aggregate version and command idempotency key; +2. append the canonical Task event; +3. update the current projection; +4. insert one or more ordered Outbox records; +5. persist the command result or idempotency record. + +If the transaction rolls back, none of these effects is visible. If it +commits, a delivery worker can recover the Outbox record after process crash. + +Outbox ordering is per Task. A global sequence may exist for operations, but +Web replay relies on the Task stream cursor. Delivery workers use a bounded +claim lease and make duplicate publication safe. They may compact superseded +high-frequency progress updates only when the projection contract explicitly +permits it; approvals, decisions, execution outcomes, and terminal states are +not lossy. + +The Outbox is not deleted because one browser received an item. Retention is +governed by canonical event/projection policy and per-client receipt +watermarks. + +## 9. Delivery receipt semantics + +A delivery receipt means the client reducer applied every envelope up to a +contiguous cursor. It does not mean the user saw the item, approved it, or +accepted its result. + +```text +DeliveryReceipt { + actor_id, + client_instance_id, + attachment_id, + task_id, + highest_contiguous_cursor, + projection_version, + acknowledged_at +} +``` + +Rules: + +- Receipts advance monotonically and cannot skip a gap. +- The Gateway authenticates and scopes every receipt. +- A receipt for an unknown, expired, or other Task attachment is rejected. +- Duplicate receipts are idempotent. +- A stale lower cursor is ignored, not treated as a detach. +- Receipt absence triggers retry or retention, not command rollback. +- One device's receipt does not advance another device's watermark. +- Detach includes the last applied cursor but remains a separate lifecycle + command. + +## 10. Attach, replay, and reconnect + +```mermaid +sequenceDiagram + participant W as Web client + participant G as Gateway + participant P as Projection/Outbox + + W->>G: AttachTask(task, after_cursor) + G->>P: authorize and read snapshot boundary + P-->>G: snapshot + replay + next cursor + G-->>W: attachment + snapshot + replay + W->>W: idempotently apply contiguous items + W->>G: AcknowledgeDelivery(cursor) + W->>G: OpenProjectionStream(cursor) + P-->>W: ordered envelopes + W->>G: periodic monotonic receipts + Note over W,G: connection drops + W->>G: reconnect with last applied cursor +``` + +To avoid snapshot/stream races, the attach response includes an atomic +snapshot boundary. The stream starts strictly after that boundary or includes +deduplicable overlap. + +If the requested cursor is retained, the server replays after it. If it is +expired, the server returns a typed `cursor_reset` with a fresh snapshot and +new boundary. The client replaces its Task reducer state but keeps local input +drafts only after checking their Task version. + +## 11. Commands, approvals, and concurrency + +Prompt, approval, question, and cancellation submissions use an idempotency +key generated before the first network attempt. On timeout, the client retries +with the same key or reconciles from the projection. + +Approval rules: + +- The browser never receives a Broker permit or execution credential. +- A decision includes `ApprovalId` and expected Task version. +- The Gateway resolves actor and attachment scope, then Task policy accepts or + rejects the transition. +- A UI success state appears only after command acceptance and resolved + projection reconciliation. +- “Always allow” is shown only for policy scopes supplied by the Approval + Service. +- Sensitive questions use a dedicated contract; generic projections contain + only redacted completion state. + +Multiple clients can view a Task. Mutating conversational controls require an +interaction lease or conflict-safe aggregate version, according to the command +contract. Approval policy may intentionally allow another authorized device +to decide without the conversational lease; that exception must be explicit +and audited. + +## 12. Security and privacy + +- Gateway authentication and authorization precede every query, stream, + receipt, and command. +- Browser cookies or tokens use origin, expiry, rotation, CSRF, and secure + transport controls appropriate to the deployment mode. +- Task authorization is checked on reconnect and on every command; a prior + attachment is not permanent access. +- Projection payloads are escaped and sanitized against HTML, Markdown, URL, + and terminal-control injection. +- Execution output is fetched by an authorized, expiring reference with byte + and line bounds; it is not embedded without limit in the event stream. +- Secrets and raw credentials never enter generic timeline items, logs, URLs, + or browser local storage. +- Delivery receipts and view telemetry must not contain prompt or output + content. +- The Web adapter cannot call `ExecutionTargetPort`, `AgentRuntimePort`, or the + Task store directly. + +## 13. Errors and weak networks + +| Failure | Required behavior | +| --- | --- | +| Browser offline or sleeping | Keep last safe projection, mark stale, reconnect with cursor | +| Stream drop or proxy timeout | Exponential backoff with jitter; commands remain separate requests | +| Duplicate/out-of-order envelope | Buffer only within a bound, apply contiguous sequence, request replay on gap | +| Cursor expired | Replace reducer from an authorized snapshot reset | +| Command response lost | Retry with same idempotency key and reconcile from projection | +| Gateway restart | Resume from Outbox/EventStore without losing committed changes | +| Delivery worker crash | Claim lease expires; another worker republishes safely | +| Receipt write failure | Continue bounded replay; do not claim user action failed | +| Authorization revoked | Close stream, clear sensitive cached Task data, require reauthentication | +| Execution output unavailable | Preserve typed metadata and provide retry/inspection action | +| Slow client | Disconnect after bounded queue; client replays later from cursor | + +The event stream is not a heartbeat-based source of truth. A client marks its +view current only after it has applied a contiguous cursor from an authorized +snapshot or stream. + +## 14. Migration plan + +1. Freeze Web-safe projection and stream envelope schemas in Phase 0. +2. Implement transactional projections and Outbox in Phase 1. +3. Add read-only Task list/detail queries and a deterministic Web presenter. +4. Add attach, cursor replay, live stream, and receipts. +5. Add idempotent prompt and cancel commands. +6. Add approval/question controls after interaction lease and sensitive-input + policy are accepted. +7. Add execution output inspection by bounded reference. +8. Keep Web disabled by configuration for rollback; Shell direct mode remains + independent. + +No migration step reads cosh-core JSONL or shell `InlineState` directly from +the Web layer. + +## 15. Dependencies and task breakdown + +| Work item | Owner | Depends on | +| --- | --- | --- | +| Web-safe projection schemas | Presentation contract owner | Phase 0 schemas and redaction classes | +| HTTP command/query adapter | Gateway | Phase 1 Gateway API | +| Transactional Outbox publisher | Task Plane | EventStore/projection transaction ADR | +| Ordered stream adapter | Presentation delivery | Outbox claim and cursor contract | +| Delivery receipt endpoint/store | Gateway + Task Plane | Attachment identity | +| Browser reducer and components | Web presentation | Projection golden fixtures | +| Interaction lease UX | Web + Task Plane | Lease commands and conflict taxonomy | +| Approval/question UX | Web presentation | Approval and sensitive-input contracts | +| Output reference viewer | Web + Execution evidence | Authorized bounded-output API | +| Weak-network integration suite | Web presentation | Deterministic proxy/failure harness | + +## 16. Test strategy + +### Contract and reducer tests + +- JSON schema compatibility for snapshots, envelopes, commands, errors, and + receipts. +- Golden rendering for every projection item and redaction class. +- Duplicate, overlap, gap, reset, and out-of-order reducer behavior. +- Bounded Markdown/HTML/URL injection fixtures. + +### Transaction and delivery tests + +- Crash before and after event/projection/Outbox transaction commit. +- Delivery worker lease expiry, retry, duplicate publication, and ordering. +- Receipt monotonicity, gap rejection, multi-device watermarks, and detach. +- Cursor retention reset and snapshot/stream race coverage. + +### End-to-end tests + +- Create/attach/prompt/stream/approve/cancel/detach/reattach against fake + Runtime and Execution Target ports. +- Browser refresh, offline interval, Gateway restart, slow stream, lost command + response, and authorization revocation. +- Confirm that no Web path can reach ACP, a PTY, Task storage, or Execution + Target directly. + +Real provider, ECS, public-network, and browser screenshot validation require +separate explicit requests. They are not performed by this design work. + +## 17. Open questions + +- Is SSE sufficient for the first deployment environment, or does a known + proxy require an alternate stream binding immediately? +- What Task event and receipt retention periods satisfy weak-network clients + without unbounded storage? +- Which views are available to read-only collaborators versus Task operators? +- Should interaction leases be per Task, per Run, or per pending input? +- Which execution outputs may be cached by a service worker, if any? +- Is the first Web surface local-only, remotely exposed through an existing + control plane, or both? The answer changes authentication deployment, not + Task semantics. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design_zh.md new file mode 100644 index 0000000000..f977d16534 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/phase-2/web-presentation/design_zh.md @@ -0,0 +1,428 @@ +# Phase 2 Web 与 Presentation 设计 + +[English](design.md) + +状态:规划中,在基线 +`6c115aefe04ace0d169a24fa7cd55ad7c1befa52` 上尚未实现。 + +相关文档:[阶段规划](../../README_zh.md)和[验收报告](acceptance_zh.md)。 + +## 1. 决策 + +Phase 2 Web Client 是 COSH Gateway API 与 versioned Task projection 上的 +Presentation Adapter。它永不直接连接 ACP、Agent subprocess、PTY、Task +database 或 Outbox table。 + +首个 delivery profile 使用: + +- 通过 Gateway 传输 versioned HTTP JSON command 和 query; +- 带 replay cursor 的有序 server event stream; +- Task Plane 内 transactional Projection 与 Outbox write; +- 每个 Client 对最高连续 applied cursor 的 delivery receipt; +- Cursor 超出 retention 时执行 snapshot reset。 + +首版 stream binding 优先选择 SSE,因为 command 继续使用显式 HTTP request, +reconnect 使用 cursor。未来可以在相同 Presentation Port 后增加 WebSocket; +它不是 ACP remote transport,也不得改变 Task 语义。 + +## 2. 目标与非目标 + +### 目标 + +- 无需登录 Shell 即可从 Browser 查看 Task state 与 Agent progress。 +- 通过与其他 Client 相同的 Gateway contract 实现 attach、detach、replay、 + prompt、approve、answer、cancel 和 result inspection。 +- 在 refresh、reconnect、device sleep、弱网和 daemon restart 时可靠交付有序 + projection change。 +- 让 Presentation-specific layout 与 Task/Runtime schema 分离。 +- 在限制并发 interactive action 的同时支持多个 viewer。 +- 对每次 read 和 command 保持 redaction、authorization 与 target scope。 + +### 非目标 + +- 实现 in-browser ACP Client 或在 WebSocket 上暴露 ACP stdio。 +- 渲染或控制用户 foreground PTY。 +- 让 Browser local storage 成为事实来源。 +- 让 Web server 直接访问 database、Outbox、Agent 或 OS execution。 +- 保证 exactly-once network delivery。本设计提供有序 at-least-once delivery、 + 幂等 application 与 receipt。 +- Phase 2 实现通用钉钉/飞书 Channel Adapter。 +- 实现完整的 Warp 风格 Terminal emulator 或 code editor。 + +## 3. 当前源码证据 + +| 证据 | 当前行为 | Phase 2 缺口 | +| --- | --- | --- | +| [`ui/agent_render`](../../../../../crates/cosh-shell/src/ui/agent_render/mod.rs) | Terminal-specific panel、markdown、approval、question、activity 与 tool rendering | Rendering model 不是 versioned network projection contract | +| [`runtime/dispatcher.rs`](../../../../../crates/cosh-shell/src/runtime/dispatcher.rs) | In-process Shell event snapshot 驱动 UI action | 没有跨进程 replay、authorization 或 delivery receipt | +| [`runtime/state.rs`](../../../../../crates/cosh-shell/src/runtime/state.rs) | View 和 lifecycle state 保存在 `InlineState` | 状态是 process-local 且 Shell-specific | +| [`shell_host/lifecycle.rs`](../../../../../crates/cosh-shell/src/shell_host/lifecycle.rs) | 脱敏 Shell event 写入 local JSONL journal | Journal 不是 Task projection、EventStore 或 Web API | +| [`cosh-core/protocol.rs`](../../../../../crates/cosh-core/src/protocol.rs) | 内部 JSONL stream 包含 message、tool、question、approval 和 result | 该私有 protocol 不能暴露给 Browser | +| [`cosh-core/session.rs`](../../../../../crates/cosh-core/src/session.rs) | 按 workspace 持久化 provider conversation | Provider history 不是授权的 multi-client Task view | + +基线没有 Web crate、Browser bundle、Gateway HTTP API、SSE endpoint、Task +projection、Outbox、delivery receipt 或 Web authentication path。 + +## 4. 架构与 Ownership + +```mermaid +flowchart LR + B["Browser"] -->|"HTTP commands/queries"| GW["Gateway API"] + B <--> |"event stream + cursor"| STREAM["Presentation stream"] + GW --> TC["TaskCoordinator"] + TC --> TX["Transactional commit"] + TX --> ES[("Task/Event store")] + TX --> PROJ[("Projection")] + TX --> OB[("Outbox")] + OB --> DW["Delivery worker"] + DW --> STREAM + B -->|"delivery receipt"| GW + GW --> RECEIPT[("Receipt watermark")] +``` + +### Gateway 拥有 + +- Authentication、actor resolution、authorization、rate limit、schema + negotiation、command validation 与 idempotency admission。 +- Presentation Port 上的 query 与 stream endpoint。 +- Receipt validation 与 attachment lifecycle command。 + +### Task Plane 拥有 + +- Canonical Task event、aggregate state、projection、event sequence、Outbox + record、attachment record 和 interaction lease。 +- Atomic command result:Task event、projection update 和 Outbox record 在一个 + transaction boundary 内提交。 + +### Delivery Worker 拥有 + +- Claim pending Outbox record、发布有序 projection envelope、retry/backoff、 + lease recovery 与 delivery metric。 +- 它不解释 domain policy,也不把用户 action 标记为已接受。 + +### Browser 拥有 + +- Layout、filter、local navigation、幂等 view reducer、last applied cursor 和 + 临时 input draft。 +- Browser state 可丢弃,并且永不授权 Task transition。 + +## 5. Gateway Surface + +准确 path 由 Phase 1 Gateway API 冻结,但 Phase 2 Web Adapter 需要这些概念 +operation: + +```text +CreateTask +GetTaskProjection +ListAuthorizedTasks +AttachTask +DetachTask +SubmitPrompt +ResolveApproval +AnswerQuestion +CancelRun +ClaimInteraction / RenewInteraction / ReleaseInteraction +ReadExecutionOutput +OpenProjectionStream(after_cursor) +AcknowledgeDelivery(highest_contiguous_cursor) +``` + +每个 mutation 携带: + +```text +actor identity resolved by Gateway +task_id +expected aggregate/projection version where applicable +idempotency_key +client_instance_id +attachment_id when attached interaction is required +``` + +Web Adapter 接收 unauthorized、conflict、stale version、already resolved、 +invalid state、rate limited 与 temporarily unavailable 的 typed error。HTTP +connection close 不能被解释为成功。 + +## 6. Projection Schema + +Projection 面向安全 Presentation 优化,不用于 Runtime rehydration。 + +### Task Summary + +```text +TaskSummaryView { + task_id, + title, + status, + current_run_id?, + target_summary, + created_at, + updated_at, + version, + unread_or_attention_state +} +``` + +### Task Detail + +```text +TaskDetailView { + summary, + agent_session_summary?, + plan, + timeline_items, + pending_approvals, + pending_questions, + executions, + usage?, + attachments, + available_actions, + projection_version, + snapshot_cursor +} +``` + +### Stream Envelope + +```text +ProjectionEnvelope { + schema_version, + task_id, + sequence, + item_id, + event_type, + occurred_at, + projection_version, + payload, + redaction_class, + replay +} +``` + +`available_actions` 是建议性 UI 数据,Gateway command validation 仍具有权威性。 +Raw model payload、secret、environment value、无界 Terminal output 与 provider +credential 不得进入 generic projection。 + +## 7. Runtime 与 ACP Event Presentation + +Task Plane 在 Presentation 前对 Runtime-specific event 归一化: + +| Domain projection item | Web Component | +| --- | --- | +| Task/Run state | Header 和 status timeline | +| Agent message/thought chunk | 根据 policy 控制可见性的 grouped message block | +| Agent plan | Structured plan list | +| Tool use 与 update | Tool activity card | +| Approval pending/resolved | Decision card 和 immutable receipt | +| Question pending/answered | Input card 和 answer state | +| Execution state/output reference | Execution card 和 paged output viewer | +| Usage update | Context/cost indicator | +| Runtime failure/recovery | Recovery notice 和 available action | + +ACP `messageId`、`toolCallId`、`terminalId` 与 `sessionId` 永远不是 public Task +identity。Presenter 可以收到稳定且归一化的 message/tool item ID,但不消费 ACP +JSON-RPC。 + +## 8. Outbox 与 Transaction 语义 + +每次用户可见 Task transition 都必须在一个 database transaction 内: + +1. 验证 aggregate version 和 command idempotency key; +2. 追加 canonical Task event; +3. 更新 current projection; +4. 插入一个或多个有序 Outbox record; +5. 持久化 command result 或 idempotency record。 + +Transaction rollback 时所有效果都不可见。Commit 后,即使 process crash, +delivery worker 也能恢复 Outbox record。 + +Outbox 按 Task 排序。可以为运维提供 global sequence,但 Web replay 依赖 Task +stream cursor。Delivery worker 使用有界 claim lease,并允许安全重复发布。只有 +projection contract 明确允许时,才能 compact 已被取代的高频 progress update; +approval、decision、execution outcome 与 terminal state 不能 lossy。 + +不能因为一个 Browser 收到 item 就删除 Outbox。Retention 由 canonical event/ +projection policy 和每个 Client 的 receipt watermark 管理。 + +## 9. Delivery Receipt 语义 + +Delivery receipt 表示 Client reducer 已应用到某个连续 cursor 的全部 envelope, +不表示用户看到 item、批准它或接受结果。 + +```text +DeliveryReceipt { + actor_id, + client_instance_id, + attachment_id, + task_id, + highest_contiguous_cursor, + projection_version, + acknowledged_at +} +``` + +规则: + +- Receipt 单调前进,不能跨过 gap。 +- Gateway 认证每个 receipt 并限定 scope。 +- Unknown、expired 或其他 Task attachment 的 receipt 被拒绝。 +- 重复 receipt 幂等。 +- 过低 stale cursor 被忽略,不按 detach 处理。 +- 缺失 receipt 触发 retry 或 retention,不触发 command rollback。 +- 一个 device 的 receipt 不推进另一个 device 的 watermark。 +- Detach 包含 last applied cursor,但仍是独立 lifecycle command。 + +## 10. Attach、Replay 与 Reconnect + +```mermaid +sequenceDiagram + participant W as Web client + participant G as Gateway + participant P as Projection/Outbox + + W->>G: AttachTask(task, after_cursor) + G->>P: authorize and read snapshot boundary + P-->>G: snapshot + replay + next cursor + G-->>W: attachment + snapshot + replay + W->>W: idempotently apply contiguous items + W->>G: AcknowledgeDelivery(cursor) + W->>G: OpenProjectionStream(cursor) + P-->>W: ordered envelopes + W->>G: periodic monotonic receipts + Note over W,G: connection drops + W->>G: reconnect with last applied cursor +``` + +为了避免 snapshot/stream race,attach response 包含 atomic snapshot boundary。 +Stream 严格从该 boundary 之后开始,或包含可 dedup 的 overlap。 + +Requested cursor 仍在 retention 内时,Server 从其后 replay。过期时返回 typed +`cursor_reset`、fresh snapshot 和新 boundary。Client 替换 Task reducer state, +同时只在校验 Task version 后保留 local input draft。 + +## 11. Command、Approval 与 Concurrency + +Prompt、approval、question 和 cancellation submission 使用首次网络尝试前生成的 +idempotency key。Timeout 时,Client 使用相同 key retry,或通过 projection +reconcile。 + +Approval 规则: + +- Browser 永不接收 Broker permit 或 execution credential。 +- Decision 包含 `ApprovalId` 与 expected Task version。 +- Gateway 解析 actor 和 attachment scope,随后由 Task policy 接受或拒绝 + transition。 +- UI 只在 command 被接受并完成 resolved projection reconciliation 后显示成功。 +- 只有 Approval Service 提供 policy scope 时才显示 “Always allow”。 +- 敏感 question 使用专用 contract;generic projection 只包含脱敏 completion + state。 + +多个 Client 可以查看同一 Task。Mutating conversational control 根据 command +contract 要求 interaction lease 或 conflict-safe aggregate version。Approval +policy 可以明确允许另一个 authorized device 在没有 conversational lease 时 +作决定;该例外必须显式且可审计。 + +## 12. 安全与隐私 + +- Gateway authentication 与 authorization 先于每次 query、stream、receipt 和 + command。 +- Browser cookie 或 token 根据 deployment mode 使用恰当的 origin、expiry、 + rotation、CSRF 与 secure transport control。 +- 重连和每次 command 都重新检查 Task authorization;旧 attachment 不等于永久 + access。 +- Projection payload 必须防御 HTML、Markdown、URL 与 Terminal-control + injection。 +- Execution output 通过 authorized、expiring reference 按 byte/line bound 获取, + 不得无界嵌入 event stream。 +- Secret 和 raw credential 永不进入 generic timeline item、log、URL 或 Browser + local storage。 +- Delivery receipt 和 view telemetry 不包含 prompt 或 output content。 +- Web Adapter 不能直接调用 `ExecutionTargetPort`、`AgentRuntimePort` 或 Task + store。 + +## 13. Error 与弱网 + +| 故障 | 必须的行为 | +| --- | --- | +| Browser offline 或 sleep | 保留最后安全 projection,标记 stale,带 cursor 重连 | +| Stream drop 或 proxy timeout | 带 jitter 的 exponential backoff;command 保持独立 request | +| Duplicate/out-of-order envelope | 只在有界范围 buffer,应用连续 sequence,遇 gap 请求 replay | +| Cursor 过期 | 从 authorized snapshot reset 替换 reducer | +| Command response 丢失 | 使用相同 idempotency key retry,并从 projection reconcile | +| Gateway restart | 从 Outbox/EventStore resume,不丢 committed change | +| Delivery worker crash | Claim lease expiry,由其他 worker 安全重发 | +| Receipt write failure | 继续有界 replay;不声称用户 action 失败 | +| Authorization revoked | 关闭 stream,清除 sensitive cached Task data,要求 reauthentication | +| Execution output unavailable | 保留 typed metadata,并提供 retry/inspection action | +| Slow client | 超过 bounded queue 后断开;Client 稍后从 cursor replay | + +Event stream 不是 heartbeat-based source of truth。Client 只有从 authorized +snapshot 或 stream 应用了连续 cursor 后,才能把 view 标记为 current。 + +## 14. 迁移计划 + +1. 在 Phase 0 冻结 Web-safe projection 与 stream envelope schema。 +2. 在 Phase 1 实现 transactional projection 和 Outbox。 +3. 增加 read-only Task list/detail query 与 deterministic Web Presenter。 +4. 增加 attach、cursor replay、live stream 和 receipt。 +5. 增加 idempotent prompt 和 cancel command。 +6. Interaction lease 和 sensitive-input policy 验收后,增加 approval/question + control。 +7. 通过有界 reference 增加 execution output inspection。 +8. 通过配置禁用 Web 以支持 rollback;Shell direct mode 保持独立。 + +任何迁移步骤都不允许 Web layer 直接读取 cosh-core JSONL 或 Shell +`InlineState`。 + +## 15. 依赖与任务分解 + +| Work item | Owner | 依赖 | +| --- | --- | --- | +| Web-safe projection schema | Presentation contract owner | Phase 0 schema 和 redaction class | +| HTTP command/query adapter | Gateway | Phase 1 Gateway API | +| Transactional Outbox publisher | Task Plane | EventStore/projection transaction ADR | +| Ordered stream adapter | Presentation delivery | Outbox claim 与 cursor contract | +| Delivery receipt endpoint/store | Gateway + Task Plane | Attachment identity | +| Browser reducer 与 component | Web presentation | Projection golden fixture | +| Interaction lease UX | Web + Task Plane | Lease command 与 conflict taxonomy | +| Approval/question UX | Web presentation | Approval 与 sensitive-input contract | +| Output reference viewer | Web + Execution evidence | Authorized bounded-output API | +| Weak-network integration suite | Web presentation | Deterministic proxy/failure harness | + +## 16. 测试策略 + +### Contract 与 Reducer Test + +- Snapshot、envelope、command、error 和 receipt 的 JSON schema compatibility。 +- 每种 projection item 和 redaction class 的 golden rendering。 +- Duplicate、overlap、gap、reset 与 out-of-order reducer 行为。 +- 有界 Markdown/HTML/URL injection fixture。 + +### Transaction 与 Delivery Test + +- Event/projection/Outbox transaction commit 前后的 crash。 +- Delivery worker lease expiry、retry、duplicate publication 和 ordering。 +- Receipt monotonicity、gap rejection、multi-device watermark 与 detach。 +- Cursor retention reset 和 snapshot/stream race coverage。 + +### 端到端 Test + +- 面向 fake Runtime 与 Execution Target Port 的 create/attach/prompt/stream/ + approve/cancel/detach/reattach。 +- Browser refresh、offline interval、Gateway restart、slow stream、lost command + response 与 authorization revocation。 +- 确认没有 Web 路径可以直接访问 ACP、PTY、Task storage 或 Execution Target。 + +Real provider、ECS、public-network 和 Browser screenshot validation 需要单独明确 +请求,本次设计工作没有执行。 + +## 17. 开放问题 + +- 首个部署环境使用 SSE 是否足够,还是已知 proxy 要求立即提供其他 stream + binding? +- 怎样的 Task event 与 receipt retention period 可以支持弱网 Client,又不会 + 造成无界存储? +- Read-only collaborator 与 Task operator 分别可以访问哪些 view? +- Interaction lease 应按 Task、Run 还是 pending input 设置? +- 哪些 execution output 可以由 service worker cache? +- 首个 Web surface 是 local-only、通过现有 control plane 远端暴露,还是两者 + 都支持?答案影响 authentication deployment,但不改变 Task 语义。 diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison.md new file mode 100644 index 0000000000..b2a10bd763 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison.md @@ -0,0 +1,166 @@ +# Warp Comparison and COSH Positioning + +[中文版](warp-comparison_zh.md) + +## Scope and evidence rule + +This comparison uses Warp's public product and architecture documentation. It +describes externally documented logical boundaries, not an inferred private +backend topology. Warp is a shipped product; the COSH Phase 0-2 side is a +target architecture whose implementation status is governed by this planning +set's acceptance reports. + +## Warp's public logical architecture + +Warp distinguishes its Agentic Development Environment from Oz, the +orchestration platform. Public enterprise documentation also separates a +Warp-hosted control plane from hosted or customer-hosted execution. + +```mermaid +flowchart TB + subgraph Entry["Surfaces and triggers"] + ADE["Warp ADE\nTerminal + BlockList + Editor"] + WEB["Oz Web / Session Viewer"] + INT["Slack / Linear / CI / Schedule"] + API["Oz CLI / API / SDK"] + ACP["External ACP Agents\npublic roadmap"] + end + + subgraph Control["Warp control plane"] + OZ["Oz Orchestrator"] + OBS["Tracking / Sharing / Observability"] + CFG["Profiles / Rules / Skills / MCP / Secrets"] + INF["Inference routing"] + end + + subgraph Execution["Execution plane"] + PTY["Local PTY / Agent"] + HC["Warp-hosted ephemeral containers"] + SH["Self-hosted worker or existing CI"] + end + + LLM["LLM providers / BYO inference"] + + ADE <--> OZ + WEB <--> OZ + INT --> OZ + API <--> OZ + ACP -. "Warp as ACP Client" .-> ADE + OZ <--> OBS + CFG --> OZ + ADE --> PTY + OZ --> HC + OZ --> SH + OZ --> INF --> LLM +``` + +Publicly documented properties include: + +- Warp is the day-to-day terminal and coding surface; Oz supplies local/cloud + Agent orchestration, triggers, environments, hosts, API/SDK, and visibility. +- Cloud automation creates a tracked task, prepares a Docker environment, + executes the Agent, publishes results, and destroys the ephemeral container. +- Enterprise self-hosted execution keeps code, commands, artifacts, secrets, + and execution logs on customer infrastructure while orchestration, + observability, inference routing, and enabled session sharing traverse the + Warp control plane. +- The open client uses an ordered typed `BlockList`: terminal command/output + blocks and rich Agent content share one virtualized stream. +- Warp's public ACP roadmap makes Warp an ACP Client so external Agent harnesses + can use its native Agent UX and enable fully client-side local-model paths. + The planning set does not treat that roadmap item as a completed production + capability without a released implementation and conformance evidence. + +Sources: + +- [Warp Oz Platform](https://docs.warp.dev/platform/overview/) +- [Warp architecture and deployment](https://docs.warp.dev/enterprise/enterprise-features/architecture-and-deployment) +- [Warp environments](https://docs.warp.dev/platform/environments/) +- [Warp Block Model](https://www.warp.dev/blog/block-model-behind-warps-agentic-development-environment) +- [Warp ACP roadmap](https://github.com/warpdotdev/warp/issues/9233) +- [Warp bring-your-own-inference roadmap](https://www.warp.dev/blog/bring-your-own-inference-to-warp) + +## Same-layer comparison + +| Dimension | Warp / Oz | COSH Phase 0-2 target | +| --- | --- | --- | +| Product center | Agentic Development Environment plus programmable Agent orchestration | Local-first Agent OS gateway plus governed GuestOS execution | +| Primary workload | Software development, repository automation, cloud software workflows | Shell and OS operations, GuestOS/ECS diagnosis and controlled remediation | +| Main interaction model | Warp Terminal/ADE, Oz Web, integrations, CLI/API/SDK | Equal Shell, Web, CLI/API, and future channel attachments | +| Durable control unit | Oz Agent run/task and shared session transcript | COSH `Task` aggregate, independent `Run`, approval, delivery, and execution identities | +| Client UI model | Typed terminal and rich-content blocks in one `BlockList` | Channel-neutral task projections rendered as terminal cards, Web views, or channel messages | +| Runtime abstraction | Warp/Oz Agent and third-party harness direction | `AgentRuntimePort` with `CoshCoreBridge`, ACP v1 Client Bridge, and local model adapter | +| Execution environment | Local PTY; hosted ephemeral Docker; self-hosted worker or existing orchestrator | Local PTY; typed OS operators; registered GuestOS/ECS targets; later isolated remote connectors | +| Agent tool access | Terminal, files, Skills, MCP, profiles, rules, environment and platform policy | Every Shell, operator, Skill, MCP, and ACP tool intent passes through Capability Broker | +| Approval | Product permission controls and interactive approvals | Durable Task transition followed by target-bound permit and auditable execution | +| Cross-device | Cloud-backed Oz dashboard, sharing, Web/mobile monitoring | Gateway projections, cursored replay, Outbox delivery, explicit attachment leases | +| Offline/local inference | Fully client-side local harness and ACP path publicly described as planned | A first-class future Runtime adapter; Phase 0-2 preserves the boundary but does not claim the model runtime exists | +| ACP role | Planned client-side bridge from external harness to Warp UX | Client-side bridge from external Agent to COSH Task and OS governance | +| Remote protocol | Oz APIs and platform connectivity | COSH Gateway API; remote ACP is outside Phase 0-2 | +| Security boundary | Central platform policy with selectable execution placement | Installation identity, target grants, capability decision, permit, audit, checkpoint/evidence references | + +## The important architectural difference + +Warp's strongest architectural asset is the integrated developer surface: its +Block model lets humans and Agents work in one terminal/editor stream, while +Oz adds programmable orchestration and cloud visibility. Reproducing panes, +blocks, cloud run dashboards, or a generic coding Agent would place COSH in a +mature competitor's center of gravity. + +COSH should instead make the OS side effect the center of its architecture: + +```text +user or Agent intent + -> durable Task decision + -> identity and target grants + -> capability evaluation / approval + -> target-bound permit + -> typed or interactive execution + -> audit, checkpoint/evidence, and result projection +``` + +The terminal remains valuable, but it becomes one privileged attachment and +one possible execution host. DingTalk, Feishu, Web, CLI, and automation can +submit or observe the same Task without acquiring terminal or root authority. + +## What COSH should learn from Warp + +- Separate the product surface from the orchestration plane. +- Treat local and background execution as different placements of one tracked + work object. +- Build UI from typed events and projections rather than scraping plain text. +- Make detach, reattach, transcript visibility, and intervention normal + lifecycle behavior. +- Keep environments, Agent behavior, host placement, and per-run context as + distinct configuration concepts. +- Expose programmatic APIs early enough that chat integrations are adapters, + not special execution paths. + +## What COSH should not copy in Phase 0-2 + +- A new terminal renderer or BlockList implementation. +- Pane, tab, or process-manager feature parity. +- A generic cloud coding-Agent control plane before local durability and OS + governance work. +- A Warp-compatible private protocol inferred from UI behavior. +- ACP as a substitute for Task storage, channel delivery, identity, or policy. +- Remote ACP transport before the standard and COSH security ADR are stable. + +## ACP strategic value in this comparison + +Both products benefit from acting as ACP Clients because the user-facing +surface no longer needs a custom integration for every Agent harness. For COSH, +that interoperability has additional leverage: the same external Agent can be +placed behind COSH approvals, deterministic operators, target grants, audit, +and weak-network recovery. + +ACP is therefore foundational at the Runtime boundary, but it is not the +foundation of the whole COSH system. The Task Plane and Capability Broker are +the durable and security foundations; ACP is the replaceability boundary. + +## Positioning statement + +> COSH is a local-first Agent OS gateway for individual developers, small +> teams, and GuestOS fleets. It makes Agent runtimes replaceable through ACP +> v1 while keeping every OS side effect durable, governed, auditable, and +> recoverable. diff --git a/src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison_zh.md b/src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison_zh.md new file mode 100644 index 0000000000..868098c6c3 --- /dev/null +++ b/src/cosh-ng/docs/design/acp-v1-phase-0-2/warp-comparison_zh.md @@ -0,0 +1,150 @@ +# Warp 对比与 COSH 定位 + +[English](warp-comparison.md) + +## 范围与证据规则 + +本对比只使用 Warp 公开产品和架构资料,描述外部可确认的逻辑边界,不推断其 +未公开的后端拓扑。Warp 是已交付产品;COSH Phase 0-2 是目标架构,实现状态以 +本规划集的验收报告为准。 + +## Warp 公开逻辑架构 + +Warp 把 Agentic Development Environment 与编排平台 Oz 分开。公开企业文档 +还把 Warp-hosted Control Plane 与托管或客户自建 Execution Plane 分开。 + +```mermaid +flowchart TB + subgraph Entry["入口与 Trigger"] + ADE["Warp ADE\nTerminal + BlockList + Editor"] + WEB["Oz Web / Session Viewer"] + INT["Slack / Linear / CI / Schedule"] + API["Oz CLI / API / SDK"] + ACP["外部 ACP Agents\n公开路线图"] + end + + subgraph Control["Warp Control Plane"] + OZ["Oz Orchestrator"] + OBS["Tracking / Sharing / Observability"] + CFG["Profiles / Rules / Skills / MCP / Secrets"] + INF["Inference routing"] + end + + subgraph Execution["Execution Plane"] + PTY["Local PTY / Agent"] + HC["Warp-hosted 临时 Container"] + SH["Self-hosted Worker 或现有 CI"] + end + + LLM["LLM providers / BYO inference"] + + ADE <--> OZ + WEB <--> OZ + INT --> OZ + API <--> OZ + ACP -. "Warp 作为 ACP Client" .-> ADE + OZ <--> OBS + CFG --> OZ + ADE --> PTY + OZ --> HC + OZ --> SH + OZ --> INF --> LLM +``` + +公开资料可以确认以下性质: + +- Warp 是日常 Terminal 与 Coding Surface;Oz 提供本地/云端 Agent 编排、Trigger、 + Environment、Host、API/SDK 和可见性。 +- Cloud Automation 创建 tracked task、准备 Docker environment、执行 Agent、发布 + 结果,最后销毁临时 container。 +- Enterprise self-hosted execution 把代码、命令、artifact、secret 和 execution log + 留在客户环境,但 orchestration、observability、inference routing 和已启用的 + session sharing 仍经过 Warp Control Plane。 +- 开源客户端采用有序 typed `BlockList`,Terminal command/output block 与 Agent + rich content 共用一条虚拟化 stream。 +- Warp 公开 ACP 路线是让 Warp 充当 ACP Client,使外部 Agent harness 使用原生 + Agent UX,并打开全客户端端侧模型路径。没有发布实现与 conformance 证据时, + 本规划集不把这条路线图能力视为已稳定交付。 + +资料包括: + +- [Warp Oz Platform](https://docs.warp.dev/platform/overview/) +- [Warp 架构与部署](https://docs.warp.dev/enterprise/enterprise-features/architecture-and-deployment) +- [Warp Environments](https://docs.warp.dev/platform/environments/) +- [Warp Block Model](https://www.warp.dev/blog/block-model-behind-warps-agentic-development-environment) +- [Warp ACP 路线图](https://github.com/warpdotdev/warp/issues/9233) +- [Warp BYO Inference 路线](https://www.warp.dev/blog/bring-your-own-inference-to-warp) + +## 同层比较 + +| 维度 | Warp / Oz | COSH Phase 0-2 目标 | +| --- | --- | --- | +| 产品中心 | Agentic Development Environment 加可编程 Agent Orchestration | 本地优先 Agent OS Gateway 加受治理的 GuestOS Execution | +| 核心工作负载 | 软件开发、Repository Automation、Cloud Software Workflow | Shell 与 OS 运维、GuestOS/ECS 诊断和受控修复 | +| 主要交互模型 | Warp Terminal/ADE、Oz Web、Integration、CLI/API/SDK | 同级 Shell、Web、CLI/API 和未来 Channel Attachment | +| 持久控制单元 | Oz Agent run/task 与 shared session transcript | COSH `Task` aggregate,独立 `Run`、approval、delivery 和 execution identity | +| Client UI model | 单一 `BlockList` 中的 typed Terminal 与 Rich Content Block | Channel-neutral Task Projection,渲染成 Terminal card、Web view 或 Channel message | +| Runtime 抽象 | Warp/Oz Agent 与第三方 harness 方向 | `AgentRuntimePort` 后接 `CoshCoreBridge`、ACP v1 Client Bridge 与端侧模型 Adapter | +| Execution Environment | Local PTY、托管临时 Docker、自托管 Worker 或现有 Orchestrator | Local PTY、Typed OS Operator、已注册 GuestOS/ECS Target,后续隔离 Remote Connector | +| Agent Tool Access | Terminal、Files、Skills、MCP、Profile、Rule、Environment 和平台 Policy | 每个 Shell、Operator、Skill、MCP 和 ACP Tool Intent 都通过 Capability Broker | +| Approval | 产品权限控制与交互审批 | 持久 Task transition,随后签发 target-bound permit 并审计执行 | +| Cross-device | 云端 Oz Dashboard、Sharing、Web/Mobile Monitoring | Gateway Projection、Cursor Replay、Outbox Delivery、显式 Attachment Lease | +| Offline/Local Inference | 公开规划全客户端 Local Harness 与 ACP 路径 | 一等未来 Runtime Adapter;Phase 0-2 保留边界但不声称模型 Runtime 已存在 | +| ACP 作用 | 计划把外部 Harness 接进 Warp UX 的客户端 Bridge | 把外部 Agent 接进 COSH Task 与 OS Governance 的客户端 Bridge | +| Remote Protocol | Oz API 与平台连接 | COSH Gateway API;Remote ACP 不属于 Phase 0-2 | +| Security Boundary | 集中平台 Policy 与可选 Execution Placement | Installation Identity、Target Grant、Capability Decision、Permit、Audit 与 Checkpoint/Evidence Reference | + +## 最重要的架构差异 + +Warp 最强的架构资产是集成开发界面。Block Model 让人和 Agent 在同一条 +Terminal/Editor stream 中工作,Oz 再增加可编程编排和云端可见性。复制 pane、 +block、cloud run dashboard 或通用 coding Agent,会让 COSH 进入成熟竞品的主场。 + +COSH 应把 OS 副作用放到架构中心: + +```text +用户或 Agent intent + -> 持久 Task decision + -> Identity 与 Target Grant + -> Capability Evaluation / Approval + -> Target-bound Permit + -> Typed 或 Interactive Execution + -> Audit、Checkpoint/Evidence 和 Result Projection +``` + +Terminal 仍然重要,但它成为一个特权 Attachment 和一种 Execution Host。钉钉、 +飞书、Web、CLI 与 Automation 可以提交或观察同一个 Task,却不会自动取得 Terminal +或 root 权限。 + +## COSH 应向 Warp 学习什么 + +- 把 Product Surface 与 Orchestration Plane 分开。 +- 把 Local 与 Background Execution 视为同一个 tracked work object 的不同 placement。 +- UI 从 typed event 和 projection 构建,而不是抓取纯文本。 +- 把 detach、reattach、transcript visibility 和 intervention 变成普通生命周期能力。 +- Environment、Agent Behavior、Host Placement 和 Per-run Context 使用不同配置概念。 +- 尽早开放 programmatic API,使 Chat Integration 成为 Adapter 而不是特殊执行路径。 + +## Phase 0-2 不应复制什么 + +- 新 Terminal Renderer 或 BlockList 实现。 +- Pane、Tab 或 Process Manager 功能对齐。 +- 在本地持久性与 OS Governance 前先做通用 Cloud Coding-Agent Control Plane。 +- 根据 UI 行为推断并兼容 Warp 私有协议。 +- 用 ACP 替代 Task Storage、Channel Delivery、Identity 或 Policy。 +- 在标准和 COSH Security ADR 稳定前使用 Remote ACP Transport。 + +## 对比中的 ACP 战略价值 + +两类产品都能从 ACP Client 获益,因为用户界面不再需要为每个 Agent harness 写一套 +定制集成。对 COSH 来说还有额外价值,同一个外部 Agent 可以进入 COSH Approval、 +确定性 Operator、Target Grant、Audit 和弱网恢复边界。 + +因此 ACP 是 Runtime Boundary 的基础,却不是整个 COSH 系统的基础。Task Plane 与 +Capability Broker 分别提供持久性和安全基础,ACP 提供可替换性边界。 + +## 定位表述 + +> COSH 是面向个人开发者、小团队和 GuestOS Fleet 的本地优先 Agent OS Gateway。 +> 它通过 ACP v1 让 Agent Runtime 可替换,同时保证每次 OS 副作用持久、受治理、 +> 可审计、可恢复。 diff --git a/src/cosh-ng/rust-toolchain.toml b/src/cosh-ng/rust-toolchain.toml index 73cb934de4..7855e6d557 100644 --- a/src/cosh-ng/rust-toolchain.toml +++ b/src/cosh-ng/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "stable" +channel = "1.88.0" components = ["rustfmt", "clippy"]